Give walks the two sleep pattern views

Walks had the overview tile, the window list and minutes per day, but not the
two views that answer the questions sleep already answers: when in the day does
it actually happen, and is today keeping up.

"When … walks" is the sleep actogram drawn from walk windows, so the drawing
moves into drawActogram(svgId, windows, barCls) and both timelines become one
line each. Nothing about that picture was ever sleep-specific — it clips
windows to day rows and shades them — so the third caller costs nothing.

"Walk trend" is the sleep trend's shape with three deliberate differences.
Today and yesterday are built from their own walk boundaries rather than hourly
samples, so the steps stay square: a 30-minute walk is a step, not an hour-wide
ramp. The average keeps hourly sampling, since a mean over many days is a
smooth reference with no steps of its own to preserve. And there is no
projected tail — sleep is a state the puppy drifts back into, so continuing
today's line by what the average day adds is a fair guess, while walks are
decisions, and projecting them would be predicting the handler, not the dog.

The goal is a line, not a band: the five-minute rule gives one number to reach
with no upper bound a band would imply. With no projection to carry it, the ✓
that marks being on track goes on today's own chip.

The axis stays linear where the sleep trend stretches above 10h. That stretch
exists because sleep's interesting hours crowd a 16h goal; a walk total reads
the same at 10 minutes as at 60.

Both panels sit with the other walk views rather than beside their sleep twins,
following the grouping-by-subject the charts just moved to, and both hide
themselves until a walk exists so they cost nothing to anyone not logging them.
This commit is contained in:
Alexander Heldt
2026-08-31 22:07:38 +00:00
parent 079eb41672
commit 8f9e8783fa
4 changed files with 273 additions and 8 deletions
+214 -8
View File
@@ -1601,12 +1601,14 @@
// ---------- pattern charts ----------
// Actogram: one row per day (oldest at top), a midnight-to-midnight track with
// the puppy's sleep shaded. Sleep windows are clipped to each day, so a night
// that crosses midnight shows correctly split across two rows. Today's open
// sleep runs to now (sleepWindows already clips ongoing sleep to Date.now()).
function renderSleepTimeline(events) {
const svg = document.getElementById("chart-sleep-timeline");
// Actogram: one row per day (oldest at top), a midnight-to-midnight track
// with the given windows shaded. Windows are clipped to each day, so one that
// crosses midnight shows correctly split across two rows, and an open one
// runs to now (both window builders already clip ongoing to Date.now()).
// Sleep and walks are the same picture drawn from different windows, so the
// drawing lives here once and each caller brings its windows and their fill.
function drawActogram(svgId, windows, barCls) {
const svg = document.getElementById(svgId);
if (!svg) return;
const N = chartDays();
const W = 320;
@@ -1621,7 +1623,6 @@
const dayMs = 86_400_000;
const today = startOfDay(new Date());
const windows = sleepWindows(events);
const xOf = (frac) => ML + frac * innerW;
const parts = [];
@@ -1650,7 +1651,7 @@
if (e <= s) continue;
const x = xOf((s - dayStart) / dayMs);
const wpx = ((e - s) / dayMs) * innerW;
parts.push(`<rect class="stl-sleep" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(0.6, wpx).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5"/>`);
parts.push(`<rect class="${barCls}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(0.6, wpx).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5"/>`);
}
const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`;
@@ -1663,6 +1664,207 @@
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
}
function renderSleepTimeline(events) {
drawActogram("chart-sleep-timeline", sleepWindows(events), "stl-sleep");
}
// The walk patterns are only worth a panel once there is a walk to draw, so
// both hide themselves rather than showing an empty day grid to someone who
// doesn't track walks.
function renderWalkPatterns(events) {
const windows = walkWindows(events);
const any = windows.length > 0;
for (const key of ["walk-timeline", "walk-trend"]) {
const section = document.querySelector(`section[data-panel="${key}"]`);
if (section) section.hidden = !any;
}
if (!any) return;
drawActogram("chart-walk-timeline", windows, "wtl-walk");
renderWalkTrend(events);
}
// Cumulative minutes walked through the day, as a step per walk: flat while
// the puppy is in, climbing only while a walk is on. Today and yesterday are
// built from their own walk boundaries so the steps stay square; the average
// is sampled hourly instead, since a mean over many days is a smooth
// reference with no steps of its own to preserve.
//
// Unlike the sleep trend there is no projected tail. Sleep is a state the
// puppy drifts back into, so "what the average day adds from here" is a fair
// guess; walks are decisions, and projecting them would be predicting the
// handler, not the dog.
function walkTrendCurves(events) {
const HOUR = 3_600_000, MIN = 60_000, DAY = 86_400_000;
const windows = walkWindows(events); // an ongoing walk is already clipped to now
const walkedMs = (from, to) => {
let total = 0;
for (const w of windows) {
const s = Math.max(w.start, from);
const e = Math.min(w.end, to);
if (e > s) total += e - s;
}
return total;
};
const day = selectedDay();
const isToday = ymd(day) === ymd(new Date());
const dayStartTs = (daysAgo) => {
const d = startOfDay(day);
d.setDate(d.getDate() - daysAgo);
return d.getTime();
};
const pointsAt = (start, stops) =>
stops.map(t => ({ x: (t - start) / HOUR, y: walkedMs(start, t) / MIN }));
// Every boundary falling inside the day, plus the day's two ends, is a
// corner of the step line. capTs (today only) ends it at "now".
const curveFor = (start, capTs) => {
const end = capTs != null ? capTs : start + DAY;
const stops = new Set([start, end]);
for (const w of windows) {
if (w.end <= start || w.start >= end) continue;
stops.add(Math.max(w.start, start));
stops.add(Math.min(w.end, end));
}
return pointsAt(start, [...stops].sort((a, b) => a - b));
};
const hourlyFor = (start) => {
const stops = [];
for (let h = 0; h <= 24; h++) stops.push(start + h * HOUR);
return pointsAt(start, stops);
};
const totalOf = (pts) => pts[pts.length - 1].y;
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
const prev = curveFor(dayStartTs(1));
const yesterday = totalOf(prev) > 0 ? prev : null;
// Mean of the last N days, skipping days with no walk at all so a gap in
// logging doesn't drag the average toward zero.
const avgDays = chartDays();
const dayCurves = [];
for (let i = 1; i <= avgDays; i++) {
const c = hourlyFor(dayStartTs(i));
if (c[24].y > 0) dayCurves.push(c);
}
let avg = null;
if (dayCurves.length > 0) {
avg = [];
for (let h = 0; h <= 24; h++) {
avg.push({ x: h, y: dayCurves.reduce((s, c) => s + c[h].y, 0) / dayCurves.length });
}
}
const fmtDay = (daysAgo) =>
new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" });
return {
today, yesterday, avg, avgDays,
dayLabel: isToday ? "Today" : fmtDay(0),
prevDayLabel: isToday ? "Yesterday" : fmtDay(1),
};
}
function drawWalkTrendChart(curves, target) {
const svg = document.getElementById("chart-walk-trend");
if (!svg) return;
const W = 320, H = 180;
const ML = 30, MR = 8, MT = 10, MB = 22;
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const series = [
// Reference lines first so today draws on top of them.
{ pts: curves.avg, cls: "wtrend-avg", label: `${curves.avgDays}-day average` },
{ pts: curves.yesterday, cls: "wtrend-yesterday", label: curves.prevDayLabel },
{ pts: curves.today, cls: "wtrend-today", label: curves.dayLabel },
].filter(s => s.pts && s.pts.length > 1);
// The axis has to reach the goal even on a day that fell well short of it,
// or the line it is measured against would sit off the top of the chart.
const rawMax = Math.max(
target ? target.total : 0,
...series.flatMap(s => s.pts.map(p => p.y)),
);
const { yMax, steps } = niceAxisLinear(rawMax);
// Minutes are linear all the way down, so no split axis: the sleep trend
// stretches its top because the interesting hours cluster near a 16h goal,
// while a walk total is as readable at 10 minutes as at 60.
const xOf = (hour) => ML + (hour / 24) * innerW;
const yOf = (v) => MT + innerH * (1 - v / yMax);
const parts = [];
for (let i = 0; i <= steps; i++) {
const v = yMax * i / steps;
const y = yOf(v);
parts.push(`<line class="grid" x1="${ML}" y1="${y.toFixed(1)}" x2="${W - MR}" y2="${y.toFixed(1)}"/>`);
parts.push(`<text x="${ML - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end">${Math.round(v)}m</text>`);
}
for (const hr of [0, 6, 12, 18, 24]) {
const x = xOf(hr);
parts.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${MT}" x2="${x.toFixed(1)}" y2="${MT + innerH}"/>`);
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
parts.push(`<text x="${x.toFixed(1)}" y="${H - MB + 14}" text-anchor="${anchor}">${pad2(hr)}</text>`);
}
// The five-minute rule as one line rather than a band: it is a target to
// reach, with no upper bound a band would imply.
if (target) {
const y = yOf(Math.min(target.total, yMax));
parts.push(
`<line class="wtrend-goal" x1="${ML}" y1="${y.toFixed(1)}" x2="${W - MR}" y2="${y.toFixed(1)}">` +
`<title>${escapeText(`Goal ~${target.total} min (${target.perWalk} min × ${target.walks})`)}</title></line>`
);
}
for (const s of series) {
const d = s.pts
.map((p, i) => `${i === 0 ? "M" : "L"}${xOf(p.x).toFixed(1)} ${yOf(p.y).toFixed(1)}`)
.join(" ");
const last = s.pts[s.pts.length - 1];
parts.push(
`<path class="${s.cls}" d="${d}">` +
`<title>${escapeText(`${s.label}${Math.round(last.y)} min walked`)}</title></path>`
);
}
svg.innerHTML = parts.join("");
}
function renderWalkTrend(events) {
const curves = walkTrendCurves(events);
const target = walkTargetFor(loadConfig().birthday);
drawWalkTrendChart(curves, target);
const chip = (id) => document.getElementById(id);
const mins = (pts) => `${Math.round(pts[pts.length - 1].y)} min`;
// With no projection to carry it, the ✓ goes on today's own chip: the goal
// is a total for the day, so today's line is what meets it or doesn't.
let mark = "";
if (target) {
const done = curves.today[curves.today.length - 1].y >= target.total;
mark = done ? " ✓" : "";
chip("legend-wtrend-today-text").parentElement.title = done
? "Past the walking goal for this age"
: `Goal is about ${target.total} min a day at this age`;
}
chip("legend-wtrend-today-text").textContent = `${curves.dayLabel} ${mins(curves.today)}${mark}`;
const yLegend = chip("legend-wtrend-yesterday");
yLegend.hidden = !curves.yesterday;
if (curves.yesterday) {
chip("legend-wtrend-yesterday-text").textContent = `${curves.prevDayLabel} ${mins(curves.yesterday)}`;
}
const aLegend = chip("legend-wtrend-avg");
aLegend.hidden = !curves.avg;
if (curves.avg) {
chip("legend-wtrend-avg-text").textContent = `${curves.avgDays}-day avg ${mins(curves.avg)}`;
}
const gLegend = chip("legend-wtrend-goal");
gLegend.hidden = !target;
if (target) {
chip("legend-wtrend-goal-text").textContent = `Goal ~${target.total} min`;
}
}
// Which heatmap block is focused, remembered across re-renders so a background
// sync doesn't wipe the block the user just tapped. The counts behind it are
// recomputed every render, so the readout stays current.
@@ -2344,6 +2546,8 @@
// gender; fall back to a neutral phrase when no name is configured.
const sleepTitle = document.getElementById("sleep-timeline-title");
if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping";
const walkTitle = document.getElementById("walk-timeline-title");
if (walkTitle) walkTitle.textContent = cfg.name ? `When ${cfg.name} walks` : "When walking";
}
// Dim the quick actions that don't fit the current state — a nudge
@@ -2396,6 +2600,7 @@
renderWalks(events);
renderWeekly(events);
renderSleepTimeline(events);
renderWalkPatterns(events);
renderSleepTrend(events);
renderHourHeatmap(events);
renderTraining(events);
@@ -4269,6 +4474,7 @@
renderWalks(evs);
renderWeekly(evs);
renderSleepTimeline(evs);
renderWalkPatterns(evs);
renderSleepTrend(evs);
renderHourHeatmap(evs);
renderTraining(evs);