Show the age-based sleep goal as a band in the Sleep trend chart

The puppy's age (from the configured birthday) picks the daily goal —
0–8 weeks 20–22h, 8–16 weeks 18–20h, 16–18h to 6 months, 14–16h to 12
months — drawn as a shaded band behind the curves, with a legend chip
naming the range. The Projected chip gains a ✓/▽ marker for whether
today's projection reaches the goal's lower bound, and the y-axis
always extends to cover the band. No birthday (or an adult dog) means
no band, unchanged from before.
This commit is contained in:
Alexander Heldt
2026-07-15 17:35:55 +00:00
parent 8fe8f9d417
commit 897311465c
4 changed files with 54 additions and 5 deletions
+49 -4
View File
@@ -257,6 +257,19 @@
return `${wk} · ${mo} old`;
}
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
// 08 weeks 2022h, 816 weeks 1820h, then 1618h to 6 months and 1416h to
// 12 months. No birthday (or an adult dog) → no goal.
function sleepTargetFor(birthday) {
const a = ageParts(birthday);
if (!a) return null;
if (a.weeks < 8) return { lo: 20, hi: 22, label: "08 weeks" };
if (a.weeks < 16) return { lo: 18, hi: 20, label: "816 weeks" };
if (a.months < 6) return { lo: 16, hi: 18, label: "46 months" };
if (a.months < 12) return { lo: 14, hi: 16, label: "612 months" };
return null;
}
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
const events = loadAll();
const now = Date.now();
@@ -1345,7 +1358,7 @@
return { today, yesterday, avg, avgDays, projected };
}
function drawSleepTrendChart(curves) {
function drawSleepTrendChart(curves, target) {
const svg = document.getElementById("chart-sleep-trend");
if (!svg) return;
const W = 320, H = 220;
@@ -1361,7 +1374,11 @@
{ pts: curves.today, cls: "trend-today", label: "Today" },
].filter(s => s.pts && s.pts.length > 1);
const rawMax = Math.max(...series.flatMap(s => s.pts.map(p => p.y)));
// The axis must reach the goal band's top even when the data doesn't yet.
const rawMax = Math.max(
target ? target.hi : 0,
...series.flatMap(s => s.pts.map(p => p.y)),
);
const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax);
const xOf = (hour) => ML + (hour / 24) * innerW;
@@ -1371,6 +1388,18 @@
// labels stay apart.
const yLabelCls = ySteps > 16 ? "y-dense" : "";
const parts = [];
// Age-based goal band, behind everything: today's projection should land
// inside it by midnight.
if (target) {
const yTop = yOf(target.hi);
const yBot = yOf(target.lo);
parts.push(
`<rect class="trend-goal" x="${ML}" y="${yTop.toFixed(1)}" ` +
`width="${innerW}" height="${(yBot - yTop).toFixed(1)}">` +
`<title>${escapeText(`Goal ${target.lo}${target.hi}h (${target.label})`)}</title></rect>`
);
}
for (let i = 0; i <= ySteps; i++) {
const y = MT + innerH * (1 - i / ySteps);
const v = Math.round(yMax * i / ySteps * 10) / 10;
@@ -1399,7 +1428,8 @@
function renderSleepTrend(events) {
const curves = sleepTrendCurves(events);
drawSleepTrendChart(curves);
const target = sleepTargetFor(loadConfig().birthday);
drawSleepTrendChart(curves, target);
// Drop legend chips for missing reference lines (no data for them yet),
// keep the average chip's label in step with the chart-days picker, and
// write each curve's slept-hours total into its chip.
@@ -1419,7 +1449,22 @@
const pLegend = chip("legend-trend-projected");
pLegend.hidden = !curves.projected;
if (curves.projected) {
chip("legend-trend-projected-text").textContent = `Projected ~${hrs(curves.projected)}`;
// ✓ when the projection reaches at least the goal's lower bound.
let mark = "";
if (target) {
const end = curves.projected[curves.projected.length - 1].y;
mark = end >= target.lo ? " ✓" : " ▽";
pLegend.title = end >= target.lo
? "On track for the sleep goal"
: "Projected below the sleep goal";
}
chip("legend-trend-projected-text").textContent = `Projected ~${hrs(curves.projected)}${mark}`;
}
const gLegend = chip("legend-trend-goal");
gLegend.hidden = !target;
if (target) {
chip("legend-trend-goal-text").textContent =
`Goal ${target.lo}${target.hi}h (${target.label})`;
}
}