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.
This commit is contained in:
Alexander Heldt
2026-07-15 09:55:26 +00:00
parent b030cfb72a
commit 8dc4fca4e2
4 changed files with 163 additions and 0 deletions
+124
View File
@@ -1241,6 +1241,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).
@@ -1619,6 +1741,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);
@@ -2573,6 +2696,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();
+1
View File
@@ -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": "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": "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": "Weight and amount fields no longer show up on events that don't use them (e.g. editing a pee)" },
+11
View File
@@ -220,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>
+27
View File
@@ -900,6 +900,33 @@ input.switch:checked::after { transform: translateX(18px); }
.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); }