From b42ea2e309c2eedaab2535bac4fd94b7b6dea0b8 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Mon, 24 Aug 2026 13:14:45 +0000 Subject: [PATCH] Draw each timing row as a range chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pair of numbers per type says how long the gaps are but not where the puppy currently sits between them, which is the thing you actually want when deciding whether to go out now. Each row becomes a small chart instead: a band from the shortest to the typical gap, a marker for time since the last one, and a scale of [min(shortest, since), max(typical, since)] so the marker is free to land inside the band, off its left end (just went) or off its right end (due). Three cases the layout has to survive. A gap in tracking can leave "since" at days against a typical gap of hours, so past twice the typical the marker parks at the right edge behind a chevron and its label keeps the real number — squashing the band to a sliver would be worse, and clamping silently would read as being in range. A band too narrow to hang a label off each end carries both values centred over its middle rather than pinned to the track ends, where they would imply a spread the band does not have. Under two events there is no gap at all, so the row falls back to a sentence instead of an axis with nothing on it. The marker is ink over a surface-coloured ring so it stays legible on any band colour in either theme, and the gap between a label's word and its value is an explicit dx: a trailing space inside a tspan does not survive XML whitespace normalisation. --- src/app.js | 150 +++++++++++++++++++++++++++++++++++++++++---- src/changelog.json | 1 + src/index.html | 26 +++++--- src/style.css | 28 +++++++++ 4 files changed, 187 insertions(+), 18 deletions(-) diff --git a/src/app.js b/src/app.js index fbbc5c7..6c97a35 100644 --- a/src/app.js +++ b/src/app.js @@ -692,19 +692,28 @@ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } + // Each type gets a small range chart: a band spanning the shortest to the + // typical gap over the window, and a marker for how long it has been since + // the last one. The marker is free to sit outside the band — just went (left + // of shortest) or overdue (right of typical) — which is the reading that + // decides whether to take the puppy out now. + const TIMING_ROWS = [ + { type: "pee", label: "Pees", noun: "pee", cls: "tm-pee" }, + { type: "poo", label: "Poos", noun: "poo", cls: "tm-poo" }, + { type: "eat", label: "Meals", noun: "meal", cls: "tm-eat" }, + ]; + function renderTiming(events) { const peeGaps = gapsBetween(events, "pee"); - const pooGaps = gapsBetween(events, "poo"); - const eatGaps = gapsBetween(events, "eat"); - const show = (id, ms) => { - document.getElementById(id).textContent = ms == null ? "—" : formatDuration(ms); - }; - show("gap-pee", median(peeGaps)); - show("gap-pee-min", peeGaps[0] ?? null); - show("gap-poo", median(pooGaps)); - show("gap-poo-min", pooGaps[0] ?? null); - show("gap-eat", median(eatGaps)); - show("gap-eat-min", eatGaps[0] ?? null); + + for (const row of TIMING_ROWS) { + const svg = document.getElementById(`timing-chart-${row.type}`); + const note = document.getElementById(`timing-note-${row.type}`); + if (!svg || !note) continue; + const gaps = row.type === "pee" ? peeGaps : gapsBetween(events, row.type); + const last = lastEventOfType(events, row.type); + drawTimingChart(svg, note, row, gaps, last ? Math.max(0, Date.now() - last.at) : null); + } const hint = document.getElementById("timing-hint"); const typicalPee = median(peeGaps); @@ -717,6 +726,125 @@ } } + function drawTimingChart(svg, note, row, gaps, since) { + const typical = median(gaps); + const shortest = gaps[0] ?? null; + + // Fewer than two events in the window means there is no gap to draw, so the + // row falls back to a sentence rather than an axis with nothing on it. + if (typical == null) { + svg.innerHTML = ""; + svg.style.display = "none"; + note.hidden = false; + note.textContent = since == null + ? `No ${row.noun}s logged in the last 7 days.` + : `One ${row.noun} logged, ${formatDuration(since)} ago — log another to see the typical gap.`; + return; + } + svg.style.display = ""; + note.hidden = true; + + const W = 320, H = 50, ML = 26, MR = 26; + const innerW = W - ML - MR; + const trackY = 20, trackH = 8; + + // A gap in tracking can leave "since" at days while the typical gap is + // hours, which would squash the band to a sliver. Past twice the typical + // the marker parks at the right edge behind a chevron; its label still + // carries the real number, so nothing reads as being in range when it isn't. + const cap = typical * 2; + const over = since != null && since > cap; + const cur = since == null ? null : (over ? cap : since); + + let lo = Math.min(shortest, cur ?? shortest); + let hi = Math.max(typical, cur ?? typical); + if (!(hi > lo)) { const pad = Math.max(60_000, hi * 0.25); lo -= pad; hi += pad; } + const xOf = (v) => ML + ((v - lo) / (hi - lo)) * innerW; + + // The band collapses to a dot when a single gap is all there is, so it gets + // centred on the point instead of growing to the right of it. + const bandA = xOf(shortest), bandB = xOf(typical); + const bandW = Math.max(trackH + 2, bandB - bandA); + const bandX = bandB - bandA < trackH + 2 ? (bandA + bandB) / 2 - bandW / 2 : bandA; + + const parts = [ + ``, + `` + + `${escapeText(`Usually ${formatDuration(shortest)}–${formatDuration(typical)} between ${row.noun}s`)}`, + ]; + + // Ticks tie each label to the exact point it describes, which is what keeps + // a centred label honest when the band is too narrow to hang one off each end. + const tickY = trackY + trackH + 5; + const tick = (x) => + ``; + const labelY = tickY + 13; + // Whitespace inside a is at the mercy of XML normalisation (an + //   does not survive every renderer either), so the gap between a word + // and its value is an explicit dx offset. + const word = (w, dx) => `${w}`; + const val = (w, ms, dx) => `${word(w, dx)}${escapeText(formatDuration(ms))}`; + // Rough advance width for the 9px label font — only needs to be close + // enough to stop a centred label running off either edge. + const widthOf = (plain) => plain.length * 5; + const centred = (markup, plain, x) => { + const half = widthOf(plain) / 2; + const cx = Math.min(Math.max(x, half + 4), W - half - 4); + return `${markup}`; + }; + + const mid = (bandA + bandB) / 2; + if (bandB - bandA < 4) { + // One gap, or gaps that round to the same figure: a single point to name. + parts.push(tick(mid)); + parts.push(centred(val("typical", typical), `typical ${formatDuration(typical)}`, mid)); + } else if (bandB - bandA < 150) { + // Too narrow to hang a label off each end without the two touching, so + // both values ride together over the middle of the band rather than being + // pushed out to the track ends, where they would imply a spread the band + // doesn't have. + parts.push(tick(bandA), tick(bandB)); + const plain = `shortest ${formatDuration(shortest)} · typical ${formatDuration(typical)}`; + parts.push(centred( + `${val("shortest", shortest)}${word("·", 4)}${val("typical", typical, 4)}`, + plain, mid)); + } else { + parts.push(tick(bandA), tick(bandB)); + parts.push(`${val("shortest", shortest)}`); + parts.push(`${val("typical", typical)}`); + } + + if (cur != null) { + const x = over ? W - MR : xOf(cur); + const y1 = trackY - 5, y2 = trackY + trackH + 5; + // Surface-coloured underlay: a ring that keeps the marker legible + // wherever on the band it lands. + parts.push(``); + parts.push( + `` + + `${escapeText(`${formatDuration(since)} since the last ${row.noun}`)}` + ); + if (over) { + parts.push( + `` + ); + } + const anchor = x < ML + 55 ? "start" : (x > W - MR - 55 ? "end" : "middle"); + parts.push( + `` + + `${escapeText(`${formatDuration(since)} ago`)}` + ); + } + + svg.innerHTML = parts.join(""); + svg.setAttribute("aria-label", + `${row.label}: typically ${formatDuration(typical)} between ${row.noun}s, ` + + `shortest ${formatDuration(shortest)}` + + (since == null ? "" : `, ${formatDuration(since)} since the last one`) + "."); + } + function renderLasts(events) { const setLast = (id, type) => { const ev = lastEventOfType(events, type); diff --git a/src/changelog.json b/src/changelog.json index 5ac85ca..47856d7 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -1,4 +1,5 @@ [ + { "date": "2026-08-24", "text": "Each row of the timing panel is now a small chart instead of a number: the band spans the shortest to the typical gap over the last 7 days, and the marker is how long it has been since the last one. Inside the band means there is time yet, off the right-hand end means the puppy is due — and a row with only one event so far says so instead of drawing an empty axis" }, { "date": "2026-08-24", "text": "The timing panel now covers meals too — typical and shortest time between them, alongside the pee and poo gaps — so you can see the feeding rhythm the same way. It is titled just “Timing” now that it is no longer only about bathroom breaks" }, { "date": "2026-08-24", "text": "The “By hour of day” chart is now tappable: tap any block to read what it counts (“3 meals between 07:00 and 08:00”) just under the chart — until now that number only showed as a hover tooltip, which phones never get. The block you tapped gets a ring; tap it again to clear it" }, { "date": "2026-08-21", "text": "The sleep timer pill in the day bar — the one that appears once you've scrolled past the big timer — is now tappable: tap it while the puppy is asleep to log the wake-up, or while awake to log a sleep start, without scrolling back up to the buttons" }, diff --git a/src/index.html b/src/index.html index e2d24ce..0e541bd 100644 --- a/src/index.html +++ b/src/index.html @@ -171,13 +171,25 @@

Timing (last 7 days)

-
-
Typical time between pees
-
Shortest between pees
-
Typical time between poos
-
Shortest between poos
-
Typical time between meals
-
Shortest between meals
+ +
+
+
Pees
+ + +
+
+
Poos
+ + +
+
+
Meals
+ + +

diff --git a/src/style.css b/src/style.css index 4edaecd..582c2b4 100644 --- a/src/style.css +++ b/src/style.css @@ -352,6 +352,34 @@ button.danger { background: var(--danger); } font-weight: normal; } .timing-hint { margin: 10px 4px 0; line-height: 1.4; } + +/* ---------- timing range charts ---------- */ +.timing-charts { + display: flex; + flex-direction: column; + gap: 12px; +} +.timing-name { + color: var(--muted); + font-size: 0.95rem; + padding: 0 4px; +} +.timing-chart { display: block; width: 100%; height: auto; } +/* Values wear ink, the words beside them are muted — the band alone carries + which type a chart is about (its heading says it too). */ +.timing-chart text { fill: var(--text); font-size: 9px; font-variant-numeric: tabular-nums; } +.timing-chart .tm-word { fill: var(--muted); } +.timing-chart .tm-track { fill: var(--border); } +.timing-chart .tm-tick { stroke: var(--muted); stroke-width: 1; opacity: 0.45; } +.timing-chart .tm-pee { fill: var(--pee); } +.timing-chart .tm-poo { fill: var(--poo); } +.timing-chart .tm-eat { fill: var(--eat); } +/* "Since the last one" marker: ink over a surface-coloured ring, so it stays + readable on top of any band colour in either theme. */ +.timing-chart .tm-now-ring { stroke: var(--surface); stroke-width: 7; stroke-linecap: round; } +.timing-chart .tm-now { stroke: var(--text); stroke-width: 3; stroke-linecap: round; } +.timing-chart .tm-over { fill: var(--text); } +.timing-note { margin: 2px 4px 0; } .settings-hint { color: var(--muted); font-size: 0.8rem; margin: -4px 0 4px; line-height: 1.4; } .history-controls {