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