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 {