Compare commits

..
5 Commits
Author SHA1 Message Date
Alexander Heldt 8f9e8783fa 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.
2026-08-31 22:07:38 +00:00
Alexander Heldt 079eb41672 Apply the chart window to the timing panel
The 7d/14d/30d picker already drove every chart on the page, but the timing
panel was not a chart and got missed: gapsBetween defaulted to a literal 7 and
the heading said "(last 7 days)" as fixed text, so picking 30d widened the
counts and heatmap while the pee, poo and meal gaps under them stayed on a
week. Two panels describing the same events over different windows is the kind
of disagreement nobody thinks to check for.

The default is now chartDays(), read at call time so it tracks the picker
rather than freezing at load, and the heading joins the other panels in
deriving its "(last N days)" from the same place. The two sentences that
quoted seven days — the empty-row fallback and the range tooltip — say the
picked number, and the note about the faded stretch says "the window" rather
than "the week", which is no longer always true.

An explicit days argument still wins, so a future caller wanting a fixed
window is not forced through the picker.
2026-08-31 22:01:19 +00:00
Alexander Heldt e037ab2716 Group the charts by subject, not by window
"Last 7 days" collected four charts for no better reason than sharing an axis,
which put sleep hours a full panel away from the two sleep patterns and the
counts chart nowhere near the by-hour view of the same events.

Each chart now sits with its subject. Sleep hours per day becomes its own Sleep
panel directly above the sleep timeline, so the three sleep views read in
sequence. Daily counts and Food join the heatmap in one "Pees, poos & meals"
panel: how many a day, how much food went with them, and what hours they fall
in are three views of one set of events and belong on one card. Minutes walked
per day goes to the Walks panel, which is the same move applied to the chart
the split didn't mention.

The day-window picker stays a single control, in the Sleep panel, because it
was never scoped to the panel holding it — it drives the training grid, both
sleep patterns and the counts panel too. That is unchanged, and the panel it
lives in occupies the slot the old one did, so it has not moved on screen. A
comment says so, since a global control sitting inside one card does not
announce itself. Duplicating it into each panel would work as-is (both the
labels and the buttons are addressed by querySelectorAll) if reaching it ever
becomes a scroll.

Both new panels take new data-panel keys, so anyone who had the old panels
folded gets the new ones open rather than inheriting a collapse they chose for
something else. The dead #daily-charts-title lookup goes with the heading.
2026-08-31 21:51:33 +00:00
Alexander Heldt 7a1c0f3808 Shorten the weigh-in label to "Weight"
"Weigh-in" was the longest label in the app and, since it moved into a
third-width button, close enough to the edge to wrap on a mid-size phone.
"Weight" clears it with room to spare and loses nothing: the section, the
chart and the dialog field are all called Weight already.

EVENT_LABELS follows the button rather than only the button changing. Every
type's button label and its log label are the same string today, and weight
would have been the one exception — "⚖️ Weight" to log it, "Weigh-in" in the
history row and "Log Weigh-in" on the dialog. The prose that says "weigh-ins"
as a plural noun is left alone; it reads as English, not as a label.

The changelog entry for the rows is amended again rather than gaining a
neighbour, for the same reason as last time: it is one description of one
panel, and the rename is part of what that panel now looks like.
2026-08-31 21:42:20 +00:00
Alexander Heldt 09b9b62d06 Move Ate down to the dialog row
Pee and Poo take a row of their own and Ate joins Weigh-in and Note across the
bottom, which regroups the panel by what a tap does rather than by what the
event means: the bottom row is now exactly the three buttons that open a
dialog to type a value, and the row above it exactly the two that log on one
tap. Ate was the odd one out under the old split — it sat with the pees and
poos but behaved like a weigh-in.

That hands the longest label in the app, "⚖️ Weigh-in", a third-width button.
The narrow-phone padding trim already in place was written for "🍽️ Ate" in
that slot and now serves Weigh-in instead; on a mid-size phone the label is
close enough to the edge that it may wrap to two lines, which costs a little
row height and nothing else. Shortening it to "Weight" is the fix if it does.

The changelog entry describing the rows is corrected in place rather than
answered with a second entry. The update banner diffs by entry text, so an
edited entry is already surfaced as new to anyone coming from the previous
build — and one accurate description of the layout beats two entries whose
combined reading is only correct if you apply them in order.
2026-08-31 21:39:41 +00:00
4 changed files with 335 additions and 56 deletions
+221 -15
View File
@@ -21,7 +21,7 @@
"eat": "Ate",
"pee": "Pee",
"poo": "Poo",
"weight": "Weigh-in",
"weight": "Weight",
"training": "Training",
"note": "Note",
};
@@ -727,8 +727,10 @@
// Gaps (ms) between consecutive events of `type` logged within the last
// `days` days, sorted ascending. These intervals are what tell you how
// often the puppy needs to go out.
function gapsBetween(events, type, days = 7) {
// often the puppy needs to go out. Defaults to the picked chart window, so
// the 7d/14d/30d buttons widen the timing panel along with the charts —
// read at call time, not at definition, so a change takes on the next render.
function gapsBetween(events, type, days = chartDays()) {
const cutoff = startOfDay(new Date());
cutoff.setDate(cutoff.getDate() - (days - 1));
const from = cutoff.getTime();
@@ -795,7 +797,7 @@
svg.style.display = "none";
note.hidden = false;
note.textContent = since == null
? `No ${row.noun}s logged in the last 7 days.`
? `No ${row.noun}s logged in the last ${chartDays()} days.`
: `One ${row.noun} logged, ${formatDuration(since)} ago — log another to see the typical gap.`;
return;
}
@@ -840,7 +842,7 @@
const parts = [
`<rect class="tm-track" x="${ML}" y="${trackY}" width="${innerW}" height="${trackH}" rx="${trackH / 2}"/>`,
bandRect(`tm-range ${row.cls}`, xShort, xLong,
`<title>${escapeText(`${formatDuration(shortest)}${formatDuration(longest)} between ${row.noun}s over the last 7 days`)}</title>`),
`<title>${escapeText(`${formatDuration(shortest)}${formatDuration(longest)} between ${row.noun}s over the last ${chartDays()} days`)}</title>`),
bandRect(`tm-band ${row.cls}`, xShort, xTypical,
`<title>${escapeText(`Typically ${formatDuration(typical)} between ${row.noun}s`)}</title>`),
];
@@ -1258,8 +1260,6 @@
// Sync every "(last N days)" header and the picker's active button.
function renderChartWindow() {
const n = chartDays();
const title = document.getElementById("daily-charts-title");
if (title) title.textContent = `Last ${n} days`;
document.querySelectorAll("[data-chart-days-label]").forEach(el => {
el.textContent = `(last ${n} days)`;
});
@@ -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);
+4 -1
View File
@@ -1,7 +1,10 @@
[
{ "date": "2026-08-31", "text": "Walks now get the same two pattern views sleep has. “When … walks” is a day-per-row grid shaded where a walk was on, so you can see at a glance whether the routine is actually regular or drifts around. “Walk trend” draws the minutes walked so far at each point of the day against yesterday and the average over the picked window, with the age-based goal as a line and a ✓ once the day clears it — the line climbs only while a walk is on, so every step is one walk. Both appear under the Walks panel as soon as you have logged a walk, and stay out of the way until then" },
{ "date": "2026-08-31", "text": "The 7d / 14d / 30d buttons now set the window for the Timing panel too. Until now the typical, shortest and longest gaps between pees, poos and meals were always measured over the last 7 days whatever you picked; switch to 30d and they are measured over 30, which settles down the typical gap once there is a month of history to draw on" },
{ "date": "2026-08-31", "text": "The charts are grouped by subject instead of all sharing one “Last 7 days” panel. Sleep hours per day is now its own Sleep panel, sitting just above the “When … sleeps” timeline with the rest of the sleep views. Daily counts and Food have joined the by-hour chart in one “Pees, poos & meals” panel, so how many a day, how much food went with them and what hours they fall in read together. Minutes walked per day has moved into the Walks panel. The 7d / 14d / 30d picker now sits in the Sleep panel and still sets the window for every one of these charts" },
{ "date": "2026-08-31", "text": "A sleep or walk pair is now tied together in the History log by a dotted rail down the side, in that pair's colour, running from the start row to the end row. Anything logged in between sits inside the bracket, so a pee taken on a walk reads as having happened during it. A pair still in progress, or one that ran over from yesterday, leaves its end of the rail open" },
{ "date": "2026-08-31", "text": "Added walks: tap 🦮 Walk start when you head out and 🏁 Walk end when you get home, and the time in between is counted as exercise. The pair gets its own row under the sleep buttons. Today's total and the number of walks show in the overview, every walk of the day is listed in a new Walks section (with the age-based rule of thumb of about five minutes per month of age, twice a day), and the daily charts gain a “Walks (minutes)” bar chart once you have logged one. Like sleep, only the boundary that makes sense is tappable — no walk running means Walk end is greyed out" },
{ "date": "2026-08-31", "text": "The Log event buttons are grouped into rows now instead of flowing into one grid: 😴 Sleep start and ⏰ Sleep end side by side on their own row, then 🍽️ Ate / 💧 Pee / 💩 Poo three across, then ⚖️ Weigh-in and 📝 Note. The two halves of a sleep pair can no longer end up split across a wrap, and no button is left stranded alone on the last row" },
{ "date": "2026-08-31", "text": "The Log event buttons are grouped into rows now instead of flowing into one grid: 😴 Sleep start and ⏰ Sleep end side by side on their own row, then 💧 Pee and 💩 Poo, and last the three that ask for a value — 🍽️ Ate / ⚖️ Weight / 📝 Note — three across. The two halves of a sleep pair can no longer end up split across a wrap, and no button is left stranded alone on the last row. The weigh-in button is now just ⚖️ Weight, which fits the narrower slot; weigh-ins read as “Weight” in the history log to match" },
{ "date": "2026-08-24", "text": "The timing charts now show the longest gap as well: each bar keeps its solid stretch from the shortest to the typical gap and fades on out to the longest one of the week. To stop the nightly long gap from squashing the daytime range into a sliver, the bars are stretched so the typical gap always sits dead centre — left of the middle is sooner than usual, right of it is longer, in every row" },
{ "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" },
+66 -37
View File
@@ -108,12 +108,11 @@
<section class="quick-actions">
<h2>Log event</h2>
<!-- Explicit rows, grouped by what the buttons mean, rather than one
auto-fit grid: a start/end pair has to stay side by side at every
width (a flat grid split them apart at some column counts, and
orphaned the last button on a row of its own). A row per timed
pair, then the one-tap moments, then the two that open a dialog
to type a value. -->
<!-- Explicit rows rather than one auto-fit grid: a start/end pair has
to stay side by side at every width (a flat grid split them apart
at some column counts, and orphaned the last button on a row of
its own). A row per timed pair, then the two one-tap moments, and
last the three that open a dialog to type a value. -->
<div class="actions">
<div class="action-row">
<button class="action sleep" data-type="sleep-start">😴 Sleep start</button>
@@ -123,13 +122,13 @@
<button class="action walk" data-type="walk-start">🦮 Walk start</button>
<button class="action walk" data-type="walk-end">🏁 Walk end</button>
</div>
<div class="action-row three-up">
<button class="action eat" data-type="eat">🍽️ Ate</button>
<div class="action-row">
<button class="action pee" data-type="pee">💧 Pee</button>
<button class="action poo" data-type="poo">💩 Poo</button>
</div>
<div class="action-row">
<button class="action weight" data-type="weight"> Weigh-in</button>
<div class="action-row three-up">
<button class="action eat" data-type="eat">🍽 Ate</button>
<button class="action weight" data-type="weight">⚖️ Weight</button>
<button class="action note" data-type="note">📝 Note</button>
</div>
</div>
@@ -192,7 +191,7 @@
</section>
<section class="timing" data-panel="timing">
<h2>Timing <span class="muted-note">(last 7 days)</span></h2>
<h2>Timing <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<!-- One range chart per type, drawn by drawTimingChart: the band spans
the shortest to the typical gap and the marker is how long it has
been since the last one, so a marker past the band reads as due. -->
@@ -215,7 +214,7 @@
</div>
<!-- The axis is stretched (see drawTimingChart), so say what the middle
of a bar means rather than leave it to be inferred. -->
<p class="muted-note">The middle of every bar is that type's typical gap: left of it is sooner than usual, right of it is longer, and the faded stretch runs out to the longest gap of the week.</p>
<p class="muted-note">The middle of every bar is that type's typical gap: left of it is sooner than usual, right of it is longer, and the faded stretch runs out to the longest gap in the window.</p>
<p class="muted-note timing-hint" id="timing-hint"></p>
</section>
@@ -238,36 +237,48 @@
<!-- Age-based guidance ("the five-minute rule"), only when a birthday
is set and the puppy is still growing. -->
<p id="walk-goal" class="muted-note" hidden></p>
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
</div>
</section>
<section class="weekly" data-panel="weekly">
<h2 id="daily-charts-title">Last 7 days</h2>
<!-- The two walk patterns mirror the sleep ones below, drawn from walk
windows instead of sleep windows. Both stay hidden until there is a
walk to draw, so they cost nothing to anyone not tracking walks. -->
<section class="patterns" data-panel="walk-timeline" hidden>
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="walk-trend" hidden>
<h2>Walk trend</h2>
<svg id="chart-walk-trend" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Cumulative minutes walked through the selected day, the day before it, the recent average, and the age-based daily goal"></svg>
<div class="legend">
<span class="lg wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
<span class="lg wtrend-yesterday" id="legend-wtrend-yesterday"><span class="sw"></span><span id="legend-wtrend-yesterday-text">Yesterday</span></span>
<span class="lg wtrend-avg" id="legend-wtrend-avg"><span class="sw"></span><span id="legend-wtrend-avg-text">7-day avg</span></span>
<span class="lg wtrend-goal" id="legend-wtrend-goal" hidden><span class="sw"></span><span id="legend-wtrend-goal-text">Goal</span></span>
</div>
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
</section>
<!-- The day-window picker lives in this panel but governs every
day-window chart on the page — the training grid above, both sleep
patterns below, the counts panel and the walk chart — so changing it
here changes all of them (see renderChartWindow). -->
<section class="patterns" data-panel="sleep-daily">
<h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart-days-picker" role="group" aria-label="How many days the charts cover">
<button type="button" class="ghost" data-days="7">7d</button>
<button type="button" class="ghost" data-days="14">14d</button>
<button type="button" class="ghost" data-days="30">30d</button>
</div>
<div class="chart">
<div class="chart-title">Sleep (hours)</div>
<div class="chart-title">Hours per day</div>
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
</div>
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
</div>
</div>
<div class="chart" id="walk-chart-wrap" hidden>
<div class="chart-title">Walks (minutes)</div>
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
</div>
<div class="chart" id="grams-chart-wrap" hidden>
<div class="chart-title">Food (grams)</div>
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
</div>
</section>
<section class="patterns" data-panel="sleep-timeline">
@@ -289,11 +300,29 @@
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
</section>
<section class="patterns" data-panel="hour-heatmap">
<h2>By hour of day <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
<p id="hour-heatmap-info" class="muted-note hour-point-info" aria-live="polite"></p>
<p class="muted-note">Darker = happens more often at that hour.</p>
<!-- One panel for the three views of the same events: how many a day,
how much food went with them, and what hours they fall in. -->
<section class="patterns" data-panel="counts">
<h2>Pees, poos &amp; meals <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
</div>
</div>
<div class="chart" id="grams-chart-wrap" hidden>
<div class="chart-title">Food (grams)</div>
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
</div>
<div class="chart">
<div class="chart-title">By hour of day</div>
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
<p id="hour-heatmap-info" class="muted-note hour-point-info" aria-live="polite"></p>
<p class="muted-note">Darker = happens more often at that hour.</p>
</div>
</section>
<section class="weight" data-panel="weight">
+44 -3
View File
@@ -184,8 +184,9 @@ body::before {
}
/* Keep the single row intact on narrow phones. */
@media (max-width: 370px) {
/* Three columns leave ~55px of text room on the narrowest phones; trimming
the side padding keeps "🍽️ Ate" on one line instead of wrapping. */
/* Three columns leave ~55px of text room on the narrowest phones, and
"⚖️ Weight" is the longest of the three; trimming the side padding keeps
it on one line down to about 360px. */
.action-row.three-up button { padding-left: 6px; padding-right: 6px; }
.day-bar { gap: 4px; }
.day-bar button:not(.bar-clock) { padding: 8px 7px; }
@@ -248,7 +249,7 @@ body::before {
/* Quick actions: a stack of explicit rows (see index.html) instead of one
auto-fit grid, so the grouping is the same at every width. Equal columns
within a row, two by default and three for the short-labelled moments. */
within a row, two by default and three for the dialog row at the bottom. */
.actions {
display: flex;
flex-direction: column;
@@ -1034,6 +1035,8 @@ input.switch:checked::after { transform: translateX(18px); }
stroke-width: 0.5;
}
.chart-svg .stl-sleep { fill: var(--sleep); }
/* Same actogram, walk windows instead of sleep ones (see drawActogram). */
.chart-svg .wtl-walk { fill: var(--walk); }
.chart-svg .stl-today { fill: var(--accent); font-weight: 600; }
/* Selected day: accent ring on the row's track + accent label. */
.chart-svg .stl-track.stl-selected {
@@ -1084,6 +1087,41 @@ input.switch:checked::after { transform: translateX(18px); }
.lg.trend-projected .sw { background: color-mix(in srgb, var(--sleep) 40%, var(--surface)); }
.lg.trend-yesterday .sw { background: var(--eat); }
.lg.trend-avg .sw { background: var(--weight); }
/* Walk trend: the sleep trend's line weights, in the walk palette. The goal is
a single line rather than a band — a walk total is a target to reach, with no
upper bound to overshoot. */
.chart-svg .wtrend-today {
stroke: var(--walk);
stroke-width: 2.5;
stroke-linejoin: round;
fill: none;
}
.chart-svg .wtrend-yesterday {
stroke: var(--eat);
stroke-width: 1.5;
stroke-linejoin: round;
fill: none;
opacity: 0.85;
}
.chart-svg .wtrend-avg {
stroke: var(--weight);
stroke-width: 1.5;
stroke-dasharray: 4 3;
stroke-linejoin: round;
fill: none;
opacity: 0.85;
}
.chart-svg .wtrend-goal {
stroke: var(--walk);
stroke-width: 1.5;
stroke-dasharray: 2 3;
opacity: 0.55;
}
.lg.wtrend-today .sw { background: var(--walk); }
.lg.wtrend-yesterday .sw { background: var(--eat); }
.lg.wtrend-avg .sw { background: var(--weight); }
.lg.wtrend-goal .sw { background: color-mix(in srgb, var(--walk) 45%, var(--surface)); }
.lg[hidden] { display: none; }
.chart-svg .hm-cell { stroke: none; }
@@ -1163,7 +1201,10 @@ button.ex-log {
button.ex-edit { padding: 6px 12px; flex-shrink: 0; }
.training-add { width: 100%; }
/* Charts that sit under a panel's list rather than being the whole panel, so
they need the separation a list-then-chart stack doesn't get for free. */
.training-chart { margin-top: 16px; }
.walk-chart { margin-top: 16px; }
/* ---------- collapsible panels ---------- */
section.collapsible > h2 {