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:
+124
@@ -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,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)" },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user