From 8dc4fca4e20e47dc54de93af40a24245d6b2745a Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Wed, 15 Jul 2026 09:55:26 +0000 Subject: [PATCH] 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. --- src/app.js | 124 +++++++++++++++++++++++++++++++++++++++++++++ src/changelog.json | 1 + src/index.html | 11 ++++ src/style.css | 27 ++++++++++ 4 files changed, 163 insertions(+) diff --git a/src/app.js b/src/app.js index 27d1335..2afebc1 100644 --- a/src/app.js +++ b/src/app.js @@ -1241,6 +1241,128 @@ 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(``); + parts.push(`${vText}h`); + } + for (const hr of [0, 6, 12, 18, 24]) { + const x = xOf(hr); + parts.push(``); + const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle"; + parts.push(`${pad2(hr)}`); + } + + 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(`${escapeText(title)}`); + } + + 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 ---------- // 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). @@ -1619,6 +1741,7 @@ renderWakeWindows(events); renderWeekly(events); renderSleepTimeline(events); + renderSleepTrend(events); renderHourHeatmap(events); renderTraining(events); renderWeight(events); @@ -2573,6 +2696,7 @@ renderWakeWindows(evs); renderWeekly(evs); renderSleepTimeline(evs); + renderSleepTrend(evs); renderHourHeatmap(evs); renderTraining(evs); if (navigator.onLine && !syncing) setStatus(); diff --git a/src/changelog.json b/src/changelog.json index 52fa4d3..9cbd70d 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -1,4 +1,5 @@ [ + { "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)" }, diff --git a/src/index.html b/src/index.html index 8fc7053..9fb2ba1 100644 --- a/src/index.html +++ b/src/index.html @@ -220,6 +220,17 @@

Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.

+
+

Sleep trend

+ +
+ Today + Yesterday + 7-day avg +
+

Hours slept so far at each point of the day, against yesterday and the average over the picked chart window.

+
+

By hour of day (last 7 days)

diff --git a/src/style.css b/src/style.css index 3cb607d..0a88327 100644 --- a/src/style.css +++ b/src/style.css @@ -900,6 +900,33 @@ input.switch:checked::after { transform: translateX(18px); } .chart-svg .stl-hit { fill: transparent; cursor: pointer; } .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-pee { fill: var(--pee); } .chart-svg .hm-poo { fill: var(--poo); }