Compare commits

...

6 Commits

Author SHA1 Message Date
Alexander Heldt cebe923d68 Highlight the selected day in the per-day charts
A soft accent band behind the selected day's bars/cells (sleep, counts,
food, training heatmap) — visible even when the day's values are zero —
plus an accent-colored day label, and an accent ring on the selected
row of the sleep timeline. The band tracks the day picker, so tapping
into a chart immediately shows which slice you landed on.
2026-07-15 10:24:48 +00:00
Alexander Heldt 8dc4fca4e2 Add a Sleep trend chart: cumulative sleep today vs yesterday and the recent average
Answers "is the puppy behind on sleep right now?": cumulative hours
slept sampled at each hour boundary, today's line ending at the current
moment, with yesterday and the mean of the last N days as reference
curves. N follows the 7/14/30 chart-days picker, and days with no sleep
logged are skipped so a young log doesn't drag the average down.
2026-07-15 10:18:08 +00:00
Alexander Heldt b030cfb72a Dim quick actions that don't fit the current sleep state
Asleep: everything but Sleep end fades; awake: only Sleep end fades; no
sleep history: nothing. Buttons stay clickable — it's a visual nudge for
the thumb, not a lockout, so corrections still work.
2026-07-15 10:17:20 +00:00
Alexander Heldt 7cb59a7a23 Focus a text field when the edit dialog opens, never the date input
showModal() with no explicit focus autofocuses the first control — the
date input — which on iOS pops the native calendar over the form. Focus
weight/grams/note per event type instead, like openNoteDialog does.
2026-07-15 10:16:56 +00:00
Alexander Heldt 131346a7c7 Hide dialog weight/grams fields on event types that don't use them
The JS already sets .hidden per type, but 'dialog label { display: block }'
outranks the UA [hidden] rule, so the fields rendered anyway. Add the
explicit [hidden] override, matching the pattern used elsewhere in the file.
2026-07-15 10:16:29 +00:00
Alexander Heldt b608dfc342 Show a compact year-less date in the day bar so it fits small iPhones
The native date input can't be told to drop the year, so a button face
shows e.g. "Jul 10" and the real input sits hidden behind it, still
holding the value and opening the native picker on tap.
2026-07-15 10:13:19 +00:00
4 changed files with 288 additions and 12 deletions
+198 -8
View File
@@ -989,20 +989,27 @@
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}h</text>`); parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}h</text>`);
} }
const selYmd = ymd(selectedDay());
days.forEach((d, i) => { days.forEach((d, i) => {
const isToday = i === days.length - 1; const isToday = i === days.length - 1;
const isSel = d.ymd === selYmd;
const x = ML + i * (barW + gap); const x = ML + i * (barW + gap);
const h = (d.sleepHours / yMax) * innerH; const h = (d.sleepHours / yMax) * innerH;
const y = MT + innerH - h; const y = MT + innerH - h;
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${d.sleepHours.toFixed(1)}h`; const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${d.sleepHours.toFixed(1)}h`;
if (isSel) {
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
}
parts.push( parts.push(
`<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` + `<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` + `x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
`<title>${escapeText(title)}</title></rect>` `<title>${escapeText(title)}</title></rect>`
); );
if (showDayLabel(i, days.length)) { // The selected day always gets a label (accent-colored), even on wide
// windows that would otherwise skip it.
if (showDayLabel(i, days.length) || isSel) {
parts.push( parts.push(
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` + `<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
`${escapeText(dayLabel(d.date, isToday))}</text>` `${escapeText(dayLabel(d.date, isToday))}</text>`
); );
} }
@@ -1040,10 +1047,15 @@
{ key: "meals", label: "Meals", cls: "bar-eat" }, { key: "meals", label: "Meals", cls: "bar-eat" },
]; ];
const selYmd = ymd(selectedDay());
days.forEach((d, i) => { days.forEach((d, i) => {
const isToday = i === days.length - 1; const isToday = i === days.length - 1;
const isSel = d.ymd === selYmd;
const groupX = ML + i * (groupW + groupGap); const groupX = ML + i * (groupW + groupGap);
if (isSel) {
parts.push(`<rect class="day-highlight" x="${(groupX - groupGap / 2).toFixed(1)}" y="${MT}" width="${(groupW + groupGap).toFixed(1)}" height="${innerH}" rx="3"/>`);
}
series.forEach((s, j) => { series.forEach((s, j) => {
const val = d[s.key]; const val = d[s.key];
const x = groupX + j * (barW + innerBarGap); const x = groupX + j * (barW + innerBarGap);
@@ -1057,9 +1069,9 @@
); );
}); });
if (showDayLabel(i, days.length)) { if (showDayLabel(i, days.length) || isSel) {
parts.push( parts.push(
`<text x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` + `<text class="${isSel ? "day-label-sel" : ""}" x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
`${escapeText(dayLabel(d.date, isToday))}</text>` `${escapeText(dayLabel(d.date, isToday))}</text>`
); );
} }
@@ -1095,20 +1107,25 @@
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}</text>`); parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}</text>`);
} }
const selYmd = ymd(selectedDay());
days.forEach((d, i) => { days.forEach((d, i) => {
const isToday = i === days.length - 1; const isToday = i === days.length - 1;
const isSel = d.ymd === selYmd;
const x = ML + i * (barW + gap); const x = ML + i * (barW + gap);
const h = (d.grams / yMax) * innerH; const h = (d.grams / yMax) * innerH;
const y = MT + innerH - h; const y = MT + innerH - h;
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${Math.round(d.grams)} g`; const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${Math.round(d.grams)} g`;
if (isSel) {
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
}
parts.push( parts.push(
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` + `<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` + `x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
`<title>${escapeText(title)}</title></rect>` `<title>${escapeText(title)}</title></rect>`
); );
if (showDayLabel(i, days.length)) { if (showDayLabel(i, days.length) || isSel) {
parts.push( parts.push(
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` + `<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
`${escapeText(dayLabel(d.date, isToday))}</text>` `${escapeText(dayLabel(d.date, isToday))}</text>`
); );
} }
@@ -1157,15 +1174,17 @@
parts.push(`<text x="${x.toFixed(1)}" y="${MT - 5}" text-anchor="${anchor}">${hr}h</text>`); parts.push(`<text x="${x.toFixed(1)}" y="${MT - 5}" text-anchor="${anchor}">${hr}h</text>`);
} }
const selYmd = ymd(selectedDay());
for (let i = 0; i < N; i++) { for (let i = 0; i < N; i++) {
const day = new Date(today); const day = new Date(today);
day.setDate(day.getDate() - (N - 1 - i)); // oldest at top, today at bottom day.setDate(day.getDate() - (N - 1 - i)); // oldest at top, today at bottom
const dayStart = startOfDay(day).getTime(); const dayStart = startOfDay(day).getTime();
const dayEnd = dayStart + dayMs; const dayEnd = dayStart + dayMs;
const isToday = ymd(day) === ymd(new Date()); const isToday = ymd(day) === ymd(new Date());
const isSel = ymd(day) === selYmd;
const y = MT + i * (rowH + rowGap); const y = MT + i * (rowH + rowGap);
parts.push(`<rect class="stl-track" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`); parts.push(`<rect class="stl-track${isSel ? " stl-selected" : ""}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
for (const wdw of windows) { for (const wdw of windows) {
const s = Math.max(wdw.start, dayStart); const s = Math.max(wdw.start, dayStart);
@@ -1177,7 +1196,7 @@
} }
const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`; const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`;
parts.push(`<text class="stl-day ${isToday ? "stl-today" : ""}" x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(label)}</text>`); parts.push(`<text class="stl-day ${isToday ? "stl-today" : ""}${isSel ? " stl-sel" : ""}" x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(label)}</text>`);
const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
parts.push(`<rect class="bar stl-hit" data-day="${ymd(day)}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}"><title>${escapeText(title)}</title></rect>`); parts.push(`<rect class="bar stl-hit" data-day="${ymd(day)}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}"><title>${escapeText(title)}</title></rect>`);
@@ -1241,6 +1260,128 @@
svg.innerHTML = parts.join(""); svg.innerHTML = parts.join("");
} }
// ---------- sleep trend ----------
// Cumulative hours slept as the day progresses: today's running total (up to
// now) against yesterday's full curve and the mean of the last N full days,
// where N follows the 7/14/30 chart-days picker.
// Each curve is a list of { x: hour-of-day 0..24, y: cumulative hours }.
function sleepTrendCurves(events) {
const HOUR = 3_600_000;
const windows = sleepWindows(events); // ongoing sleep already clipped to now
const sleptMs = (from, to) => {
let total = 0;
for (const w of windows) {
const s = Math.max(w.start, from);
const e = Math.min(w.end, to);
if (e > s) total += e - s;
}
return total;
};
const dayStartTs = (daysAgo) => {
const d = startOfDay(new Date());
d.setDate(d.getDate() - daysAgo);
return d.getTime();
};
// capTs (today only) truncates the curve at "now" with a final fractional
// point, so the line visibly ends where the day currently stands.
const curveFor = (start, capTs) => {
const pts = [];
for (let h = 0; h <= 24; h++) {
const to = start + h * HOUR;
if (capTs != null && to >= capTs) {
pts.push({ x: (capTs - start) / HOUR, y: sleptMs(start, capTs) / HOUR });
break;
}
pts.push({ x: h, y: sleptMs(start, to) / HOUR });
}
return pts;
};
const today = curveFor(dayStartTs(0), Date.now());
const yesterdayCurve = curveFor(dayStartTs(1));
const yesterday = yesterdayCurve[24].y > 0 ? yesterdayCurve : null;
// Mean of the last N full days, skipping days with no sleep at all so a
// young log (or a tracking gap) doesn't drag the average toward zero.
const avgDays = chartDays();
const dayCurves = [];
for (let i = 1; i <= avgDays; i++) {
const c = curveFor(dayStartTs(i));
if (c[24].y > 0) dayCurves.push(c);
}
let avg = null;
if (dayCurves.length > 0) {
avg = [];
for (let h = 0; h <= 24; h++) {
avg.push({ x: h, y: dayCurves.reduce((s, c) => s + c[h].y, 0) / dayCurves.length });
}
}
return { today, yesterday, avg, avgDays };
}
function drawSleepTrendChart(curves) {
const svg = document.getElementById("chart-sleep-trend");
if (!svg) return;
const W = 320, H = 160;
const ML = 26, MR = 8, MT = 10, MB = 22;
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const series = [
// Reference lines first so today draws on top of them.
{ pts: curves.avg, cls: "trend-avg", label: `${curves.avgDays}-day average` },
{ pts: curves.yesterday, cls: "trend-yesterday", label: "Yesterday" },
{ pts: curves.today, cls: "trend-today", label: "Today" },
].filter(s => s.pts && s.pts.length > 1);
const rawMax = Math.max(...series.flatMap(s => s.pts.map(p => p.y)));
const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax);
const xOf = (hour) => ML + (hour / 24) * innerW;
const yOf = (v) => MT + innerH * (1 - v / yMax);
const parts = [];
for (let i = 0; i <= ySteps; i++) {
const y = MT + innerH * (1 - i / ySteps);
const v = Math.round(yMax * i / ySteps * 10) / 10;
const vText = v % 1 === 0 ? v : v.toFixed(1);
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}h</text>`);
}
for (const hr of [0, 6, 12, 18, 24]) {
const x = xOf(hr);
parts.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${MT}" x2="${x.toFixed(1)}" y2="${MT + innerH}"/>`);
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
parts.push(`<text x="${x.toFixed(1)}" y="${H - MB + 14}" text-anchor="${anchor}">${pad2(hr)}</text>`);
}
for (const s of series) {
const d = s.pts
.map((p, i) => `${i === 0 ? "M" : "L"}${xOf(p.x).toFixed(1)} ${yOf(p.y).toFixed(1)}`)
.join(" ");
const last = s.pts[s.pts.length - 1];
const title = `${s.label}${last.y.toFixed(1)}h slept`;
parts.push(`<path class="${s.cls}" d="${d}"><title>${escapeText(title)}</title></path>`);
}
svg.innerHTML = parts.join("");
}
function renderSleepTrend(events) {
const curves = sleepTrendCurves(events);
drawSleepTrendChart(curves);
// Drop legend chips for missing reference lines (no data for them yet),
// and keep the average chip's label in step with the chart-days picker.
const yLegend = document.getElementById("legend-trend-yesterday");
const aLegend = document.getElementById("legend-trend-avg");
const aText = document.getElementById("legend-trend-avg-text");
if (yLegend) yLegend.hidden = !curves.yesterday;
if (aLegend) aLegend.hidden = !curves.avg;
if (aText) aText.textContent = `${curves.avgDays}-day avg`;
}
// ---------- weight ---------- // ---------- weight ----------
// Pick a "nice" kg axis that frames the data with a little headroom rather // Pick a "nice" kg axis that frames the data with a little headroom rather
// than forcing 0-based (a puppy going 5→8 kg would otherwise look flat). // than forcing 0-based (a puppy going 5→8 kg would otherwise look flat).
@@ -1462,6 +1603,13 @@
const cellW = innerW / N; const cellW = innerW / N;
const parts = []; const parts = [];
const selCol = dayIndex.get(ymd(selectedDay()));
if (selCol !== undefined) {
parts.push(
`<rect class="day-highlight" x="${(ML + selCol * cellW - 0.75).toFixed(1)}" y="${MT - 2}" ` +
`width="${cellW.toFixed(1)}" height="${(H - MT - MB + 4).toFixed(1)}" rx="3"/>`
);
}
exercises.forEach((x, r) => { exercises.forEach((x, r) => {
const y = MT + r * (rowH + rowGap); const y = MT + r * (rowH + rowGap);
const max = Math.max(1, ...counts[r]); const max = Math.max(1, ...counts[r]);
@@ -1563,6 +1711,10 @@
const isToday = ymd(day) === ymd(new Date()); const isToday = ymd(day) === ymd(new Date());
document.getElementById("day-next").disabled = isToday; document.getElementById("day-next").disabled = isToday;
document.getElementById("day-today").disabled = isToday; document.getElementById("day-today").disabled = isToday;
// Year-less date on the picker's face — the year is implicit and the
// saved width keeps the bar on one row on small phones.
document.getElementById("day-date-face").textContent =
day.toLocaleDateString(undefined, { month: "short", day: "numeric" });
const title = document.getElementById("overview-title"); const title = document.getElementById("overview-title");
if (title) { if (title) {
title.textContent = isToday title.textContent = isToday
@@ -1586,12 +1738,28 @@
if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping"; if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping";
} }
// Dim the quick actions that don't fit the current sleep state — a nudge
// toward the likely next tap, never a block: everything stays clickable so
// corrections (a missed sleep-end, a mid-nap pee) are always possible.
function renderActionHints(events) {
const { state } = currentSleepState(events);
document.querySelectorAll("button.action").forEach(btn => {
const type = btn.dataset.type;
const unlikely =
state === "asleep" ? type !== "sleep-end"
: state === "awake" ? type === "sleep-end"
: false; // no sleep history yet — no hints to give
btn.classList.toggle("unlikely", unlikely);
});
}
function render() { function render() {
const events = live(); const events = live();
renderHeader(); renderHeader();
renderDayBar(); renderDayBar();
renderChartWindow(); renderChartWindow();
renderBigClock(events); renderBigClock(events);
renderActionHints(events);
renderStats(events); renderStats(events);
renderLasts(events); renderLasts(events);
renderTiming(events); renderTiming(events);
@@ -1599,6 +1767,7 @@
renderWakeWindows(events); renderWakeWindows(events);
renderWeekly(events); renderWeekly(events);
renderSleepTimeline(events); renderSleepTimeline(events);
renderSleepTrend(events);
renderHourHeatmap(events); renderHourHeatmap(events);
renderTraining(events); renderTraining(events);
renderWeight(events); renderWeight(events);
@@ -1970,6 +2139,14 @@
} }
renderEditPhotos(); renderEditPhotos();
editDialog.showModal(); editDialog.showModal();
// Without an explicit focus the browser autofocuses the first control —
// the date input — and iOS immediately pops the calendar over the form.
// Land on the first relevant text field instead (same trick and delay as
// openNoteDialog: synchronous focus can be lost while the dialog settles).
const focusTarget = !editWeightField.hidden ? editWeight
: !editGramsField.hidden ? editGrams
: editNote;
setTimeout(() => focusTarget.focus(), 50);
} }
editPhotoBtn.addEventListener("click", () => editPhotoInput.click()); editPhotoBtn.addEventListener("click", () => editPhotoInput.click());
@@ -2355,6 +2532,17 @@
dayPicker.value = ymd(new Date()); dayPicker.value = ymd(new Date());
dayPicker.addEventListener("change", render); dayPicker.addEventListener("change", render);
// The face opens the hidden input's native picker (iOS 16+ has showPicker;
// focus() is the fallback and is what pops the picker on older iOS anyway).
document.getElementById("day-date-face").addEventListener("click", () => {
try {
if (typeof dayPicker.showPicker === "function") dayPicker.showPicker();
else dayPicker.focus();
} catch {
dayPicker.focus();
}
});
function shiftSelectedDay(days) { function shiftSelectedDay(days) {
const d = selectedDay(); const d = selectedDay();
d.setDate(d.getDate() + days); d.setDate(d.getDate() + days);
@@ -2526,6 +2714,7 @@
const evs = live(); const evs = live();
renderHeader(); renderHeader();
renderBigClock(evs); renderBigClock(evs);
renderActionHints(evs);
renderStats(evs); renderStats(evs);
renderLasts(evs); renderLasts(evs);
renderTiming(evs); renderTiming(evs);
@@ -2533,6 +2722,7 @@
renderWakeWindows(evs); renderWakeWindows(evs);
renderWeekly(evs); renderWeekly(evs);
renderSleepTimeline(evs); renderSleepTimeline(evs);
renderSleepTrend(evs);
renderHourHeatmap(evs); renderHourHeatmap(evs);
renderTraining(evs); renderTraining(evs);
if (navigator.onLine && !syncing) setStatus(); if (navigator.onLine && !syncing) setStatus();
+6
View File
@@ -1,4 +1,10 @@
[ [
{ "date": "2026-07-15", "text": "The charts highlight the selected day, so it's easy to spot which bars, rows and cells you're looking at" },
{ "date": "2026-07-15", "text": "New Sleep trend chart: today's running sleep total through the day, against yesterday and the average over your chart window (7/14/30 days)" },
{ "date": "2026-07-15", "text": "Quick actions that don't fit right now are dimmed (asleep → everything but Sleep end; awake → Sleep end) — still tappable for corrections" },
{ "date": "2026-07-15", "text": "Editing an event no longer pops the date picker over the whole screen on iPhone" },
{ "date": "2026-07-15", "text": "Weight and amount fields no longer show up on events that don't use them (e.g. editing a pee)" },
{ "date": "2026-07-15", "text": "The date in the top bar is shorter (no year), so the whole bar fits on smaller iPhones" },
{ "date": "2026-07-13", "text": "The big timer is back at the top — scroll past it and it hops into the frozen bar instead" }, { "date": "2026-07-13", "text": "The big timer is back at the top — scroll past it and it hops into the frozen bar instead" },
{ "date": "2026-07-13", "text": "The top bar fits on one row: timer, day arrows, date picker and Today" }, { "date": "2026-07-13", "text": "The top bar fits on one row: timer, day arrows, date picker and Today" },
{ "date": "2026-07-13", "text": "Pick how many days the charts cover — 7, 14 or 30 (default 7)" }, { "date": "2026-07-13", "text": "Pick how many days the charts cover — 7, 14 or 30 (default 7)" },
+18 -1
View File
@@ -86,7 +86,13 @@
<span id="bar-clock-time"></span> <span id="bar-clock-time"></span>
</div> </div>
<button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button> <button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button>
<input type="date" id="day-picker" /> <!-- A browser won't let us shorten the text a native date input shows,
so the face button carries a compact year-less date and the real
input stays (visually hidden) as the value + native picker. -->
<span class="day-date">
<button type="button" id="day-date-face" class="ghost"></button>
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
</span>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button> <button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
<button type="button" id="day-today" class="ghost">Today</button> <button type="button" id="day-today" class="ghost">Today</button>
</section> </section>
@@ -214,6 +220,17 @@
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.</p> <p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.</p>
</section> </section>
<section class="patterns" data-panel="sleep-trend">
<h2>Sleep trend</h2>
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Cumulative sleep hours through the day: today, yesterday and the recent average"></svg>
<div class="legend">
<span class="lg trend-today"><span class="sw"></span>Today</span>
<span class="lg trend-yesterday" id="legend-trend-yesterday"><span class="sw"></span>Yesterday</span>
<span class="lg trend-avg" id="legend-trend-avg"><span class="sw"></span><span id="legend-trend-avg-text">7-day avg</span></span>
</div>
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window.</p>
</section>
<section class="patterns" data-panel="hour-heatmap"> <section class="patterns" data-panel="hour-heatmap">
<h2>By hour of day <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2> <h2>By hour of day <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg> <svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
+66 -3
View File
@@ -147,10 +147,23 @@ body::before {
flex-wrap: nowrap; flex-wrap: nowrap;
padding: 8px 10px; padding: 8px 10px;
} }
.day-bar input[type="date"] { /* The face button shows the short date; the real input sits invisibly behind
flex: 0 1 auto; it so its native picker still anchors here (and .showPicker() has a target). */
min-width: 0; .day-date {
position: relative;
flex-shrink: 0;
display: inline-flex;
}
.day-date input[type="date"] {
position: absolute;
inset: 0;
width: 100%;
opacity: 0;
pointer-events: none;
}
#day-date-face {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.day-bar button { .day-bar button {
padding: 8px 10px; padding: 8px 10px;
@@ -242,6 +255,9 @@ button.action.eat { background: var(--eat); }
button.action.pee { background: var(--pee); color: #2b240a; } button.action.pee { background: var(--pee); color: #2b240a; }
button.action.poo { background: var(--poo); } button.action.poo { background: var(--poo); }
button.action.weight { background: var(--weight); } button.action.weight { background: var(--weight); }
/* Unlikely given the current sleep state (see renderActionHints) — dimmed
but fully tappable, so corrections are never blocked. */
button.action.unlikely { opacity: 0.4; }
button.ghost { button.ghost {
background: transparent; background: transparent;
@@ -453,6 +469,9 @@ dialog::backdrop { background: rgba(0,0,0,0.4); }
dialog h3 { margin: 0 0 12px; } dialog h3 { margin: 0 0 12px; }
dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; } dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; }
/* The display rule above beats the UA [hidden] rule, so hide explicitly —
otherwise the weight/grams fields show on event types that don't use them. */
dialog label[hidden] { display: none; }
dialog label input, dialog label textarea { margin-top: 4px; } dialog label input, dialog label textarea { margin-top: 4px; }
dialog menu { dialog menu {
@@ -584,6 +603,16 @@ dialog menu {
} }
.chart-svg .bar { cursor: pointer; } .chart-svg .bar { cursor: pointer; }
.chart-svg .bar-faded { opacity: 0.7; } .chart-svg .bar-faded { opacity: 0.7; }
/* The selected day's slice in every per-day chart: a soft accent band behind
the bars/cells (visible even when the day's values are all zero) plus an
accent-colored day label. */
.chart-svg .day-highlight {
fill: var(--accent);
fill-opacity: 0.08;
stroke: var(--accent);
stroke-opacity: 0.45;
}
.chart-svg text.day-label-sel { fill: var(--accent); font-weight: 600; }
.chart-svg .bar-sleep { fill: var(--sleep); } .chart-svg .bar-sleep { fill: var(--sleep); }
.chart-svg .bar-pee { fill: var(--pee); } .chart-svg .bar-pee { fill: var(--pee); }
.chart-svg .bar-poo { fill: var(--poo); } .chart-svg .bar-poo { fill: var(--poo); }
@@ -878,9 +907,43 @@ input.switch:checked::after { transform: translateX(18px); }
} }
.chart-svg .stl-sleep { fill: var(--sleep); } .chart-svg .stl-sleep { fill: var(--sleep); }
.chart-svg .stl-today { fill: var(--accent); font-weight: 600; } .chart-svg .stl-today { fill: var(--accent); font-weight: 600; }
/* Selected day: accent ring on the row's track + accent label. */
.chart-svg .stl-track.stl-selected {
stroke: var(--accent);
stroke-width: 1.2;
stroke-opacity: 0.8;
}
.chart-svg .stl-sel { fill: var(--accent); }
.chart-svg .stl-hit { fill: transparent; cursor: pointer; } .chart-svg .stl-hit { fill: transparent; cursor: pointer; }
.chart-svg .stl-hit:hover { fill: var(--accent); fill-opacity: 0.08; } .chart-svg .stl-hit:hover { fill: var(--accent); fill-opacity: 0.08; }
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
.chart-svg .trend-today {
stroke: var(--sleep);
stroke-width: 2.5;
stroke-linejoin: round;
fill: none;
}
.chart-svg .trend-yesterday {
stroke: var(--muted);
stroke-width: 1.5;
stroke-linejoin: round;
fill: none;
opacity: 0.75;
}
.chart-svg .trend-avg {
stroke: var(--sleep);
stroke-width: 1.5;
stroke-dasharray: 4 3;
stroke-linejoin: round;
fill: none;
opacity: 0.7;
}
.lg.trend-today .sw { background: var(--sleep); }
.lg.trend-yesterday .sw { background: var(--muted); }
.lg.trend-avg .sw { background: color-mix(in srgb, var(--sleep) 55%, var(--surface)); }
.lg[hidden] { display: none; }
.chart-svg .hm-cell { stroke: none; } .chart-svg .hm-cell { stroke: none; }
.chart-svg .hm-pee { fill: var(--pee); } .chart-svg .hm-pee { fill: var(--pee); }
.chart-svg .hm-poo { fill: var(--poo); } .chart-svg .hm-poo { fill: var(--poo); }