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:
+139
-11
@@ -692,19 +692,28 @@
|
|||||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
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) {
|
function renderTiming(events) {
|
||||||
const peeGaps = gapsBetween(events, "pee");
|
const peeGaps = gapsBetween(events, "pee");
|
||||||
const pooGaps = gapsBetween(events, "poo");
|
|
||||||
const eatGaps = gapsBetween(events, "eat");
|
for (const row of TIMING_ROWS) {
|
||||||
const show = (id, ms) => {
|
const svg = document.getElementById(`timing-chart-${row.type}`);
|
||||||
document.getElementById(id).textContent = ms == null ? "—" : formatDuration(ms);
|
const note = document.getElementById(`timing-note-${row.type}`);
|
||||||
};
|
if (!svg || !note) continue;
|
||||||
show("gap-pee", median(peeGaps));
|
const gaps = row.type === "pee" ? peeGaps : gapsBetween(events, row.type);
|
||||||
show("gap-pee-min", peeGaps[0] ?? null);
|
const last = lastEventOfType(events, row.type);
|
||||||
show("gap-poo", median(pooGaps));
|
drawTimingChart(svg, note, row, gaps, last ? Math.max(0, Date.now() - last.at) : null);
|
||||||
show("gap-poo-min", pooGaps[0] ?? null);
|
}
|
||||||
show("gap-eat", median(eatGaps));
|
|
||||||
show("gap-eat-min", eatGaps[0] ?? null);
|
|
||||||
|
|
||||||
const hint = document.getElementById("timing-hint");
|
const hint = document.getElementById("timing-hint");
|
||||||
const typicalPee = median(peeGaps);
|
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
|
||||||
|
//   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) {
|
function renderLasts(events) {
|
||||||
const setLast = (id, type) => {
|
const setLast = (id, type) => {
|
||||||
const ev = lastEventOfType(events, type);
|
const ev = lastEventOfType(events, type);
|
||||||
|
|||||||
@@ -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 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-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" },
|
{ "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" },
|
||||||
|
|||||||
+19
-7
@@ -171,13 +171,25 @@
|
|||||||
|
|
||||||
<section class="timing" data-panel="timing">
|
<section class="timing" data-panel="timing">
|
||||||
<h2>Timing <span class="muted-note">(last 7 days)</span></h2>
|
<h2>Timing <span class="muted-note">(last 7 days)</span></h2>
|
||||||
<div class="lasts">
|
<!-- One range chart per type, drawn by drawTimingChart: the band spans
|
||||||
<div class="last-row"><span>Typical time between pees</span><span id="gap-pee">—</span></div>
|
the shortest to the typical gap and the marker is how long it has
|
||||||
<div class="last-row"><span>Shortest between pees</span><span id="gap-pee-min">—</span></div>
|
been since the last one, so a marker past the band reads as due. -->
|
||||||
<div class="last-row"><span>Typical time between poos</span><span id="gap-poo">—</span></div>
|
<div class="timing-charts">
|
||||||
<div class="last-row"><span>Shortest between poos</span><span id="gap-poo-min">—</span></div>
|
<div class="timing-item">
|
||||||
<div class="last-row"><span>Typical time between meals</span><span id="gap-eat">—</span></div>
|
<div class="timing-name">Pees</div>
|
||||||
<div class="last-row"><span>Shortest between meals</span><span id="gap-eat-min">—</span></div>
|
<svg id="timing-chart-pee" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
|
||||||
|
<p class="muted-note timing-note" id="timing-note-pee" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div class="timing-item">
|
||||||
|
<div class="timing-name">Poos</div>
|
||||||
|
<svg id="timing-chart-poo" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
|
||||||
|
<p class="muted-note timing-note" id="timing-note-poo" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div class="timing-item">
|
||||||
|
<div class="timing-name">Meals</div>
|
||||||
|
<svg id="timing-chart-eat" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
|
||||||
|
<p class="muted-note timing-note" id="timing-note-eat" hidden></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted-note timing-hint" id="timing-hint"></p>
|
<p class="muted-note timing-hint" id="timing-hint"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -352,6 +352,34 @@ button.danger { background: var(--danger); }
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
.timing-hint { margin: 10px 4px 0; line-height: 1.4; }
|
.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; }
|
.settings-hint { color: var(--muted); font-size: 0.8rem; margin: -4px 0 4px; line-height: 1.4; }
|
||||||
|
|
||||||
.history-controls {
|
.history-controls {
|
||||||
|
|||||||
Reference in New Issue
Block a user