diff --git a/src/app.js b/src/app.js index 064fc5f..3de51ad 100644 --- a/src/app.js +++ b/src/app.js @@ -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(``); + const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle"; + parts.push(`${hr}h`); + } + + 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(``); + + 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(``); + } + + const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`; + parts.push(`${escapeText(label)}`); + + const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); + parts.push(`${escapeText(title)}`); + } + + 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( + `` + + `${escapeText(`${s.label} ยท ${range}: ${c}`)}` + ); + } + parts.push(`${s.label}`); + }); + + const yAxis = H - MB + 12; + for (const hr of [0, 6, 12, 18]) { + const x = ML + (hr / 24) * innerW; + parts.push(`${hr}h`); + } + parts.push(`24h`); + + 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); diff --git a/src/index.html b/src/index.html index 445b6c3..c9d4c4f 100644 --- a/src/index.html +++ b/src/index.html @@ -171,6 +171,18 @@ +
+

When sleeping (last 14 days)

+ +

Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.

+
+ +
+

By hour of day (last 14 days)

+ +

Darker = happens more often at that hour.

+
+

Weight

diff --git a/src/style.css b/src/style.css index e0d2cb9..8958b83 100644 --- a/src/style.css +++ b/src/style.css @@ -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); } +