Compare commits
2 Commits
8f809b5f14
...
561b98b64f
| Author | SHA1 | Date | |
|---|---|---|---|
| 561b98b64f | |||
| f4ce7dcb54 |
+164
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user