Draw each timing row as a range chart

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.
This commit is contained in:
Alexander Heldt
2026-08-24 13:14:45 +00:00
parent 29b961c1b0
commit b42ea2e309
4 changed files with 187 additions and 18 deletions
+139 -11
View File
@@ -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 = [
`<rect class="tm-track" x="${ML}" y="${trackY}" width="${innerW}" height="${trackH}" rx="${trackH / 2}"/>`,
`<rect class="tm-band ${row.cls}" x="${bandX.toFixed(1)}" y="${trackY}" ` +
`width="${bandW.toFixed(1)}" height="${trackH}" rx="${trackH / 2}">` +
`<title>${escapeText(`Usually ${formatDuration(shortest)}${formatDuration(typical)} between ${row.noun}s`)}</title></rect>`,
];
// 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) =>
`<line class="tm-tick" x1="${x.toFixed(1)}" y1="${tickY}" x2="${x.toFixed(1)}" y2="${tickY + 4}"/>`;
const labelY = tickY + 13;
// Whitespace inside a <tspan> is at the mercy of XML normalisation (an
// &#160; does not survive every renderer either), so the gap between a word
// and its value is an explicit dx offset.
const word = (w, dx) => `<tspan class="tm-word"${dx ? ` dx="${dx}"` : ""}>${w}</tspan>`;
const val = (w, ms, dx) => `${word(w, dx)}<tspan dx="3">${escapeText(formatDuration(ms))}</tspan>`;
// 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 `<text x="${cx.toFixed(1)}" y="${labelY}" text-anchor="middle">${markup}</text>`;
};
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(`<text x="${bandA.toFixed(1)}" y="${labelY}" text-anchor="start">${val("shortest", shortest)}</text>`);
parts.push(`<text x="${bandB.toFixed(1)}" y="${labelY}" text-anchor="end">${val("typical", typical)}</text>`);
}
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(`<line class="tm-now-ring" x1="${x.toFixed(1)}" y1="${y1}" x2="${x.toFixed(1)}" y2="${y2}"/>`);
parts.push(
`<line class="tm-now" x1="${x.toFixed(1)}" y1="${y1}" x2="${x.toFixed(1)}" y2="${y2}">` +
`<title>${escapeText(`${formatDuration(since)} since the last ${row.noun}`)}</title></line>`
);
if (over) {
parts.push(
`<path class="tm-over" d="M${(x + 5).toFixed(1)} ${trackY} ` +
`L${(x + 12).toFixed(1)} ${trackY + trackH / 2} L${(x + 5).toFixed(1)} ${trackY + trackH} Z"/>`
);
}
const anchor = x < ML + 55 ? "start" : (x > W - MR - 55 ? "end" : "middle");
parts.push(
`<text class="tm-now-label" x="${x.toFixed(1)}" y="11" text-anchor="${anchor}">` +
`${escapeText(`${formatDuration(since)} ago`)}</text>`
);
}
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);