Add sleep timeline and by-hour heatmaps

Two new charts over the last 14 days, in the existing hand-rolled SVG style:

- "When he sleeps": an actogram with one row per day (oldest at top, Today
  at bottom) and a midnight-to-midnight track with sleep shaded. Sleep
  windows are clipped per day so a night crossing midnight splits across two
  rows, and today's open sleep runs to now. Tapping a row selects that day.
- "By hour of day": pee/poo/meal frequency as three 24-cell heatmap rows,
  each cell shaded by how often that event lands in that hour — surfacing
  daily rhythm the median gap can't show.

Both re-render on the one-minute tick so the current day keeps filling in.
This commit is contained in:
Alexander Heldt
2026-07-10 16:55:55 +00:00
parent 16799ad7c7
commit f4ce7dcb54
3 changed files with 151 additions and 0 deletions
+122
View File
@@ -926,6 +926,120 @@
drawCountsChart(days);
}
// ---------- pattern charts (last 14 days) ----------
const PATTERN_DAYS = 14;
// Actogram: one row per day (oldest at top), a midnight-to-midnight track with
// the puppy's sleep shaded. Sleep windows are clipped to each day, so a night
// that crosses midnight shows correctly split across two rows. Today's open
// sleep runs to now (sleepWindows already clips ongoing sleep to Date.now()).
function renderSleepTimeline(events) {
const svg = document.getElementById("chart-sleep-timeline");
if (!svg) return;
const N = PATTERN_DAYS;
const W = 320, H = 228;
const ML = 44, MR = 8, MT = 16, MB = 4;
const innerW = W - ML - MR;
const rowGap = 2;
const rowH = (H - MT - MB - (N - 1) * rowGap) / N;
const dayMs = 86_400_000;
const today = startOfDay(new Date());
const windows = sleepWindows(events);
const xOf = (frac) => ML + frac * innerW;
const parts = [];
for (const hr of [0, 6, 12, 18, 24]) {
const x = xOf(hr / 24);
parts.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${MT}" x2="${x.toFixed(1)}" y2="${H - MB}"/>`);
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
parts.push(`<text x="${x.toFixed(1)}" y="${MT - 5}" text-anchor="${anchor}">${hr}h</text>`);
}
for (let i = 0; i < N; i++) {
const day = new Date(today);
day.setDate(day.getDate() - (N - 1 - i)); // oldest at top, today at bottom
const dayStart = startOfDay(day).getTime();
const dayEnd = dayStart + dayMs;
const isToday = ymd(day) === ymd(new Date());
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"/>`);
for (const wdw of windows) {
const s = Math.max(wdw.start, dayStart);
const e = Math.min(wdw.end, dayEnd);
if (e <= s) continue;
const x = xOf((s - dayStart) / dayMs);
const wpx = ((e - s) / dayMs) * innerW;
parts.push(`<rect class="stl-sleep" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(0.6, wpx).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5"/>`);
}
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>`);
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>`);
}
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
}
// Hour-of-day heatmap: one row per event type, 24 cells shaded by how often
// that event lands in each hour across the window. Reveals daily rhythm that
// the median gap can't show (e.g. "always poos ~7am and ~6pm").
function renderHourHeatmap(events) {
const svg = document.getElementById("chart-hour-heatmap");
if (!svg) return;
const from = startOfDay(new Date(Date.now() - (PATTERN_DAYS - 1) * 86_400_000)).getTime();
const series = [
{ type: "pee", label: "Pees", cls: "hm-pee" },
{ type: "poo", label: "Poos", cls: "hm-poo" },
{ type: "eat", label: "Meals", cls: "hm-eat" },
];
const counts = {};
for (const s of series) counts[s.type] = new Array(24).fill(0);
for (const e of events) {
if (e.at < from) continue;
if (counts[e.type]) counts[e.type][new Date(e.at).getHours()]++;
}
const W = 320, H = 120;
const ML = 40, MR = 8, MT = 6, MB = 18;
const innerW = W - ML - MR;
const rowGap = 6;
const rowH = (H - MT - MB - (series.length - 1) * rowGap) / series.length;
const cellW = innerW / 24;
const parts = [];
series.forEach((s, r) => {
const y = MT + r * (rowH + rowGap);
const max = Math.max(1, ...counts[s.type]);
for (let h = 0; h < 24; h++) {
const c = counts[s.type][h];
const op = c === 0 ? 0.06 : 0.2 + 0.8 * (c / max);
const x = ML + h * cellW;
const range = `${pad2(h)}:00${pad2((h + 1) % 24)}:00`;
parts.push(
`<rect class="hm-cell ${s.cls}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" ` +
`width="${(cellW - 1).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5" fill-opacity="${op.toFixed(2)}">` +
`<title>${escapeText(`${s.label} · ${range}: ${c}`)}</title></rect>`
);
}
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${s.label}</text>`);
});
const yAxis = H - MB + 12;
for (const hr of [0, 6, 12, 18]) {
const x = ML + (hr / 24) * innerW;
parts.push(`<text x="${x.toFixed(1)}" y="${yAxis}" text-anchor="${hr === 0 ? "start" : "middle"}">${hr}h</text>`);
}
parts.push(`<text x="${(ML + innerW).toFixed(1)}" y="${yAxis}" text-anchor="end">24h</text>`);
svg.innerHTML = parts.join("");
}
// ---------- 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).
@@ -1102,6 +1216,10 @@
const ageText = formatAge(cfg.birthday);
ageEl.textContent = ageText;
ageEl.hidden = !ageText;
// Use the puppy's name in the sleep-timeline heading rather than assuming a
// gender; fall back to a neutral phrase when no name is configured.
const sleepTitle = document.getElementById("sleep-timeline-title");
if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping";
}
function render() {
@@ -1115,6 +1233,8 @@
renderSleepWindows(events);
renderWakeWindows(events);
renderWeekly(events);
renderSleepTimeline(events);
renderHourHeatmap(events);
renderWeight(events);
renderHistory(events);
}
@@ -1804,6 +1924,8 @@
renderSleepWindows(evs);
renderWakeWindows(evs);
renderWeekly(evs);
renderSleepTimeline(evs);
renderHourHeatmap(evs);
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
+12
View File
@@ -171,6 +171,18 @@
</div>
</section>
<section class="patterns" data-panel="sleep-timeline">
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note">(last 14 days)</span></h2>
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 228" role="img" aria-label="Sleep periods per day over the last 14 days"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="hour-heatmap">
<h2>By hour of day <span class="muted-note">(last 14 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 over the last 14 days"></svg>
<p class="muted-note">Darker = happens more often at that hour.</p>
</section>
<section class="weight">
<h2>Weight</h2>
<div class="weight-summary">
+17
View File
@@ -739,3 +739,20 @@ input.switch:checked::after { transform: translateX(18px); }
flex-shrink: 0;
}
.snackbar-action:hover { filter: none; background: var(--accent-soft); }
/* ---------- pattern charts (sleep timeline + hour heatmap) ---------- */
.chart-svg .stl-track {
fill: var(--bg);
stroke: var(--border);
stroke-width: 0.5;
}
.chart-svg .stl-sleep { fill: var(--sleep); }
.chart-svg .stl-today { fill: var(--accent); font-weight: 600; }
.chart-svg .stl-hit { fill: transparent; cursor: pointer; }
.chart-svg .stl-hit:hover { fill: var(--accent); fill-opacity: 0.08; }
.chart-svg .hm-cell { stroke: none; }
.chart-svg .hm-pee { fill: var(--pee); }
.chart-svg .hm-poo { fill: var(--poo); }
.chart-svg .hm-eat { fill: var(--eat); }