Compare commits

...

2 Commits

Author SHA1 Message Date
Alexander Heldt 561b98b64f Make info panels collapsible with remembered state
Each main section with a data-panel key can now be folded by clicking its
heading (rotating chevron, keyboard-accessible via role/tabindex/aria-expanded
and Enter/Space). Collapsed keys are persisted in localStorage and restored on
load, so the layout stays how you left it across reloads.

State is device-global like the theme (a single non-namespaced key, not
per-user data), and only collapsed panels are stored — so any panel added
later defaults to open.
2026-07-10 17:03:29 +00:00
Alexander Heldt f4ce7dcb54 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.
2026-07-10 17:03:29 +00:00
3 changed files with 221 additions and 7 deletions
+164
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);
}
@@ -1649,6 +1769,48 @@
if (ev) openEditDialog(ev);
});
// ---------- collapsible panels ----------
// Each main section carrying a data-panel key can be folded by clicking its
// heading; collapsed keys are remembered across reloads. State is device-global
// (like the theme), so a single non-namespaced key is fine — it's not per-user
// data. Only collapsed panels are stored, so newly added panels default open.
const PANELS_KEY = "puppy-tracker:panels:v1";
function loadPanelState() {
try {
const s = JSON.parse(localStorage.getItem(PANELS_KEY));
return s && typeof s === "object" ? s : {};
} catch { return {}; }
}
function savePanelState(state) {
try { localStorage.setItem(PANELS_KEY, JSON.stringify(state)); } catch { /* ignore */ }
}
function initPanels() {
const state = loadPanelState();
document.querySelectorAll("main section[data-panel]").forEach(section => {
const key = section.dataset.panel;
const h2 = section.querySelector(":scope > h2");
if (!h2) return;
section.classList.add("collapsible");
const collapsed = !!state[key];
section.classList.toggle("collapsed", collapsed);
h2.setAttribute("role", "button");
h2.setAttribute("tabindex", "0");
h2.setAttribute("aria-expanded", String(!collapsed));
const toggle = () => {
const nowCollapsed = section.classList.toggle("collapsed");
h2.setAttribute("aria-expanded", String(!nowCollapsed));
const s = loadPanelState();
if (nowCollapsed) s[key] = true; else delete s[key];
savePanelState(s);
};
h2.addEventListener("click", toggle);
h2.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
});
}
initPanels();
// ---------- wiring ----------
document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => {
@@ -1804,6 +1966,8 @@
renderSleepWindows(evs);
renderWakeWindows(evs);
renderWeekly(evs);
renderSleepTimeline(evs);
renderHourHeatmap(evs);
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
+19 -7
View File
@@ -97,7 +97,7 @@
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
</section>
<section class="overview">
<section class="overview" data-panel="overview">
<h2 id="overview-title">Today's overview</h2>
<div class="stats">
<div class="stat">
@@ -131,7 +131,7 @@
</div>
</section>
<section class="timing">
<section class="timing" data-panel="timing">
<h2>Bathroom timing <span class="muted-note">(last 7 days)</span></h2>
<div class="lasts">
<div class="last-row"><span>Typical time between pees</span><span id="gap-pee"></span></div>
@@ -142,19 +142,19 @@
<p class="muted-note timing-hint" id="timing-hint"></p>
</section>
<section class="sleep">
<section class="sleep" data-panel="sleep-windows">
<h2>Sleep windows</h2>
<ul id="sleep-list" class="wake-list"></ul>
<p id="sleep-empty" class="empty">No sleep windows yet for this day.</p>
</section>
<section class="wake">
<section class="wake" data-panel="wake-windows">
<h2>Wake windows</h2>
<ul id="wake-list" class="wake-list"></ul>
<p id="wake-empty" class="empty">No wake windows yet for this day.</p>
</section>
<section class="weekly">
<section class="weekly" data-panel="weekly">
<h2>Last 7 days</h2>
<div class="chart">
<div class="chart-title">Sleep (hours)</div>
@@ -171,7 +171,19 @@
</div>
</section>
<section class="weight">
<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" data-panel="weight">
<h2>Weight</h2>
<div class="weight-summary">
<div class="stat">
@@ -192,7 +204,7 @@
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</section>
<section class="history">
<section class="history" data-panel="history">
<h2>History</h2>
<ul id="event-list" class="event-list"></ul>
<p id="empty-state" class="empty">No events logged for this day.</p>
+38
View File
@@ -739,3 +739,41 @@ 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); }
/* ---------- collapsible panels ---------- */
section.collapsible > h2 {
cursor: pointer;
position: relative;
padding-right: 20px;
-webkit-user-select: none;
user-select: none;
}
section.collapsible > h2::after {
content: "▾";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
transition: transform 0.15s ease;
color: var(--muted);
font-size: 0.85em;
}
section.collapsed > h2::after { transform: translateY(-50%) rotate(-90deg); }
section.collapsed > h2 { margin-bottom: 0; }
section.collapsed > :not(h2) { display: none; }