Track walks as timed exercise

A walk is a start and an end, so it reuses the shape sleep already has rather
than inventing one: walk-start / walk-end events, paired into windows, with a
trailing unmatched start meaning "out right now". The server stores type as an
opaque string, so nothing there changes and the events ride the existing sync.

Pairing boundaries into windows was written out twice already, once for sleep
and once for its inverse, so this pulls the scan into pairWindows(open, close)
and makes all three callers of it. Same for the latest-boundary lookup behind
currentSleepState, which currentWalkState now shares — including the updatedAt
tie-break, which matters as soon as a start and an end land in the same minute.

They are called walks, not exercise. "Exercise" is already taken by the
training definitions (their own synced collection, and exerciseId on training
events), and two meanings of the word in one app would be worse than the
slightly narrower name.

The day's total leads the overview tile with the count underneath, since the
question is how much exercise the puppy got rather than how many outings it
took. The Walks panel lists the day's windows and carries the total in its
heading so a collapsed panel still answers it, and the daily charts gain a
minutes-per-day bar chart that stays hidden until there is a walk to draw —
the grams chart's rule. The panel also states the five-minute rule for the
puppy's current age, the same way the sleep trend states a goal band.

Walk boundaries answer to the walk state, not the sleep one, so "Walk end" is
disabled with no walk running and stays undimmed mid-walk even while the puppy
is logged asleep.
This commit is contained in:
Alexander Heldt
2026-08-31 21:23:40 +00:00
parent ead575df2c
commit f162ac5732
5 changed files with 243 additions and 56 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# puppy-tracker # puppy-tracker
A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos, A tiny offline-first PWA for tracking your puppy's sleep, walks, meals, pees,
weight, and training. poos, weight, and training.
The browser is the primary client; a small Go server provides a shared The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices. source-of-truth and sync between devices.
+205 -52
View File
@@ -16,6 +16,8 @@
const EVENT_LABELS = { const EVENT_LABELS = {
"sleep-start": "Sleep start", "sleep-start": "Sleep start",
"sleep-end": "Sleep end", "sleep-end": "Sleep end",
"walk-start": "Walk start",
"walk-end": "Walk end",
"eat": "Ate", "eat": "Ate",
"pee": "Pee", "pee": "Pee",
"poo": "Poo", "poo": "Poo",
@@ -522,21 +524,27 @@
return total; return total;
} }
// Current state derived from the *latest* sleep event. The single source of // The newest of a set of boundary event types. For two events sharing the
// truth for both the big clock and the "Currently" row. For two boundary // same `at` (common once "now" events are minute-floored), the one logged
// events sharing the same `at` (common once "now" events are minute-floored), // later (higher updatedAt) wins, so the tie resolves the same way everywhere
// the one logged later (higher updatedAt) wins, so the tie resolves the same // it's read.
// way everywhere it's read. function latestOfTypes(events, types) {
function currentSleepState(events) {
let latest = null; let latest = null;
for (const e of events) { for (const e of events) {
if (e.type !== "sleep-start" && e.type !== "sleep-end") continue; if (!types.includes(e.type)) continue;
if (!latest || if (!latest ||
e.at > latest.at || e.at > latest.at ||
(e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) { (e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) {
latest = e; latest = e;
} }
} }
return latest;
}
// Current state derived from the *latest* sleep event. The single source of
// truth for both the big clock and the "Currently" row.
function currentSleepState(events) {
const latest = latestOfTypes(events, ["sleep-start", "sleep-end"]);
if (!latest) return { state: null, since: 0 }; if (!latest) return { state: null, since: 0 };
return { return {
state: latest.type === "sleep-start" ? "asleep" : "awake", state: latest.type === "sleep-start" ? "asleep" : "awake",
@@ -544,6 +552,13 @@
}; };
} }
// Same idea for walks: the newest walk boundary says whether one is running.
function currentWalkState(events) {
const latest = latestOfTypes(events, ["walk-start", "walk-end"]);
if (!latest) return { walking: false, since: 0 };
return { walking: latest.type === "walk-start", since: latest.at };
}
function formatCounter(ms) { function formatCounter(ms) {
if (ms < 0) ms = 0; if (ms < 0) ms = 0;
const totalSec = Math.floor(ms / 1000); const totalSec = Math.floor(ms / 1000);
@@ -562,44 +577,55 @@
return latest; return latest;
} }
// Pair up boundary events into windows: every `openType` runs until the next
// `closeType`. A trailing unmatched `openType` is an ongoing/open window,
// measured to now. Only the two given types are considered, so the same scan
// produces sleep windows, their inverse (wake windows) and walks.
function pairWindows(events, openType, closeType) {
const sorted = events
.filter(e => e.type === openType || e.type === closeType)
.sort((a, b) => a.at - b.at);
const out = [];
let open = null;
for (const e of sorted) {
if (e.type === openType) {
open = e.at;
} else if (open !== null) {
out.push({ start: open, end: e.at, ongoing: false });
open = null;
}
}
if (open !== null) out.push({ start: open, end: Date.now(), ongoing: true });
return out;
}
// Wake windows: time between a sleep-end and the next sleep-start. The final // Wake windows: time between a sleep-end and the next sleep-start. The final
// sleep-end with no following sleep-start = ongoing/open wake window. // sleep-end with no following sleep-start = ongoing/open wake window.
function wakeWindows(events) { function wakeWindows(events) {
const sorted = events return pairWindows(events, "sleep-end", "sleep-start");
.filter(e => e.type === "sleep-start" || e.type === "sleep-end")
.sort((a, b) => a.at - b.at);
const out = [];
let waking = null;
for (const e of sorted) {
if (e.type === "sleep-end") {
waking = e.at;
} else if (e.type === "sleep-start" && waking !== null) {
out.push({ start: waking, end: e.at, ongoing: false });
waking = null;
}
}
if (waking !== null) out.push({ start: waking, end: Date.now(), ongoing: true });
return out;
} }
// Sleep windows: each sleep-start → next sleep-end pair. An unmatched // Sleep windows: each sleep-start → next sleep-end pair. An unmatched
// sleep-start = ongoing/open sleep window. // sleep-start = ongoing/open sleep window.
function sleepWindows(events) { function sleepWindows(events) {
const sorted = events return pairWindows(events, "sleep-start", "sleep-end");
.filter(e => e.type === "sleep-start" || e.type === "sleep-end") }
.sort((a, b) => a.at - b.at);
const out = []; // Walks: each walk-start → next walk-end pair, ongoing while out.
let sleeping = null; function walkWindows(events) {
for (const e of sorted) { return pairWindows(events, "walk-start", "walk-end");
if (e.type === "sleep-start") { }
sleeping = e.at;
} else if (e.type === "sleep-end" && sleeping !== null) { // Time spent walking inside [fromTs, toTs], clipping windows that straddle
out.push({ start: sleeping, end: e.at, ongoing: false }); // the edges — the same treatment sleepMsInRange gives sleep.
sleeping = null; function walkMsInRange(events, fromTs, toTs) {
} let total = 0;
for (const w of walkWindows(events)) {
const s = Math.max(w.start, fromTs);
const e = Math.min(w.end, toTs);
if (e > s) total += e - s;
} }
if (sleeping !== null) out.push({ start: sleeping, end: Date.now(), ongoing: true }); return total;
return out;
} }
function sleepWindowsForDay(events, day) { function sleepWindowsForDay(events, day) {
@@ -627,6 +653,30 @@
.map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today }));
} }
// Walks that overlap the given day. Same untrimmed-times policy as the sleep
// and wake lists: a walk that crosses midnight shows its real start and end.
function walkWindowsForDay(events, day) {
const dayStart = startOfDay(day).getTime();
const dayEnd = endOfDay(day).getTime();
const today = ymd(new Date()) === ymd(day);
return walkWindows(events)
.filter(w => w.start <= dayEnd && w.end >= dayStart)
.map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today }));
}
// Rough age-based walking guideline (the widely used "five-minute rule"):
// about 5 minutes per month of age per walk, twice a day, until the puppy is
// grown. Returns null without a birthday or once it's a year old, the same
// way sleepTargetFor bows out.
function walkTargetFor(birthday) {
const a = ageParts(birthday);
if (!a || a.months >= 12) return null;
// Under a month of counted age the rule has nothing to say yet; treat it
// as one "month" so the advice stays a short outing rather than zero.
const months = Math.max(1, a.months);
return { perWalk: months * 5, walks: 2, total: months * 10 };
}
// ---------- rendering ---------- // ---------- rendering ----------
const dayPicker = document.getElementById("day-picker"); const dayPicker = document.getElementById("day-picker");
const eventList = document.getElementById("event-list"); const eventList = document.getElementById("event-list");
@@ -663,6 +713,13 @@
const gramsEl = document.getElementById("stat-meals-grams"); const gramsEl = document.getElementById("stat-meals-grams");
gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : ""; gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : "";
gramsEl.hidden = !(gramsTotal > 0); gramsEl.hidden = !(gramsTotal > 0);
// Walk time is a duration, not a count, so the tile leads with it and puts
// "N walks" underneath — the day's exercise at a glance.
document.getElementById("stat-walk").textContent = formatDuration(walkMsInRange(events, from, to));
const walks = walkWindowsForDay(events, day).length;
const walkCountEl = document.getElementById("stat-walk-count");
walkCountEl.textContent = `${walks} walk${walks === 1 ? "" : "s"}`;
walkCountEl.hidden = walks === 0;
document.getElementById("stat-pees").textContent = count("pee"); document.getElementById("stat-pees").textContent = count("pee");
document.getElementById("stat-poos").textContent = count("poo"); document.getElementById("stat-poos").textContent = count("poo");
document.getElementById("stat-training").textContent = count("training"); document.getElementById("stat-training").textContent = count("training");
@@ -861,6 +918,11 @@
document.getElementById("last-sleep").textContent = lastSleep document.getElementById("last-sleep").textContent = lastSleep
? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})` ? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})`
: "—"; : "—";
const lastWalk = latestOfTypes(events, ["walk-start", "walk-end"]);
document.getElementById("last-walk").textContent = lastWalk
? `${EVENT_LABELS[lastWalk.type]} at ${formatTime(lastWalk.at)} (${formatRelative(lastWalk.at)})`
: "—";
} }
// Tracks the latest sleep transition so the 1-second tick can update the // Tracks the latest sleep transition so the 1-second tick can update the
@@ -973,6 +1035,31 @@
); );
} }
function renderWalks(events) {
const day = selectedDay();
const windows = walkWindowsForDay(events, day);
renderWindowList(
"walk-list", "walk-empty",
windows,
"Walking", "walk-ww",
);
// Total next to the heading, so a collapsed panel still answers "how much
// did we walk?".
const from = startOfDay(day).getTime();
const to = ymd(new Date()) === ymd(day) ? Date.now() : endOfDay(day).getTime();
const totalMs = walkMsInRange(events, from, to);
document.getElementById("walk-total").textContent =
windows.length ? `(${formatDuration(totalMs)})` : "";
const goal = walkTargetFor(loadConfig().birthday);
const goalEl = document.getElementById("walk-goal");
goalEl.textContent = goal
? `Rule of thumb at this age: about ${goal.perWalk} min per walk, ${goal.walks}× a day (~${goal.total} min).`
: "";
goalEl.hidden = !goal;
}
function renderHistory(events) { function renderHistory(events) {
const dayEvents = eventsForDay(events, selectedDay()).reverse(); const dayEvents = eventsForDay(events, selectedDay()).reverse();
const exNames = exerciseNames(); const exNames = exerciseNames();
@@ -1166,6 +1253,7 @@
grams: dayEvents grams: dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams)) .filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0), .reduce((s, e) => s + e.grams, 0),
walkMinutes: walkMsInRange(events, from, to) / 60_000,
}); });
} }
return days; return days;
@@ -1200,10 +1288,11 @@
return { yMax: m, steps: m / 5 }; return { yMax: m, steps: m / 5 };
} }
// Grams axis: 0-based with a "nice" step so tick labels stay round whatever // Magnitude-agnostic 0-based axis with a "nice" step, so tick labels stay
// the daily totals are (tens of grams for a tiny puppy, hundreds+ later). // round whatever the daily totals are (tens of grams for a tiny puppy,
// Aims for ~10 segments so day-to-day differences of a few grams show. // hundreds+ later; minutes walked likewise). Aims for ~10 segments so small
function niceAxisGrams(rawMax) { // day-to-day differences still show.
function niceAxisLinear(rawMax) {
if (!(rawMax > 0)) return { yMax: 100, steps: 4 }; if (!(rawMax > 0)) return { yMax: 100, steps: 4 };
const rawStep = rawMax / 10; const rawStep = rawMax / 10;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
@@ -1366,7 +1455,7 @@
const innerW = W - ML - MR; const innerW = W - ML - MR;
const innerH = H - MT - MB; const innerH = H - MT - MB;
const { yMax, steps: ySteps } = niceAxisGrams(Math.max(...days.map(d => d.grams))); const { yMax, steps: ySteps } = niceAxisLinear(Math.max(...days.map(d => d.grams)));
const gap = days.length > 14 ? 2 : 4; const gap = days.length > 14 ? 2 : 4;
const barW = (innerW - (days.length - 1) * gap) / days.length; const barW = (innerW - (days.length - 1) * gap) / days.length;
@@ -1407,10 +1496,65 @@
setChartSVG(svg, parts); setChartSVG(svg, parts);
} }
// Minutes walked per day. Hidden until there's a walk to show, like the
// grams chart — no point in an empty panel for someone who doesn't log walks.
function drawWalkChart(days) {
const wrap = document.getElementById("walk-chart-wrap");
const svg = document.getElementById("chart-walk");
if (!days.some(d => d.walkMinutes > 0)) { wrap.hidden = true; return; }
wrap.hidden = false;
const W = 320, H = 160;
const ML = 30, MR = 6, MT = 10, MB = 26;
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const { yMax, steps: ySteps } = niceAxisLinear(Math.max(...days.map(d => d.walkMinutes)));
const gap = days.length > 14 ? 2 : 4;
const barW = (innerW - (days.length - 1) * gap) / days.length;
const parts = [];
for (let i = 0; i <= ySteps; i++) {
const y = MT + innerH * (1 - i / ySteps);
const v = yMax * i / ySteps;
const vText = v % 1 === 0 ? v : v.toFixed(1);
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}</text>`);
}
const selYmd = ymd(selectedDay());
days.forEach((d, i) => {
const isToday = i === days.length - 1;
const isSel = d.ymd === selYmd;
const x = ML + i * (barW + gap);
const h = (d.walkMinutes / yMax) * innerH;
const y = MT + innerH - h;
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${formatDuration(d.walkMinutes * 60_000)}`;
if (isSel) {
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
}
parts.push(
`<rect class="bar bar-walk ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
`<title>${escapeText(title)}</title></rect>`
);
if (showDayLabel(i, days.length) || isSel) {
parts.push(
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
`${escapeText(dayLabel(d.date, isToday))}</text>`
);
}
});
setChartSVG(svg, parts);
}
function renderWeekly(events) { function renderWeekly(events) {
const days = weeklyData(events); const days = weeklyData(events);
drawSleepChart(days); drawSleepChart(days);
drawCountsChart(days); drawCountsChart(days);
drawWalkChart(days);
drawGramsChart(days); drawGramsChart(days);
} }
@@ -2161,31 +2305,38 @@
if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping"; if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping";
} }
// Dim the quick actions that don't fit the current sleep state — a nudge // Dim the quick actions that don't fit the current state — a nudge
// toward the likely next tap. The one boundary that would just repeat the // toward the likely next tap. A boundary that would just repeat the latest
// latest sleep event (a second "sleep start" while already asleep, or a // one of its pair (a second "sleep start" while already asleep, a "walk end"
// second "sleep end" while already awake) is disabled outright, since it // with no walk running) is disabled outright, since it can only produce a
// can only produce a zero-length window. Everything else stays clickable so // zero-length window. Everything else stays clickable so
// corrections (a mid-nap pee) are never blocked, and a genuinely missed // corrections (a mid-nap pee) are never blocked, and a genuinely missed
// boundary is still fixable from the event log, which accepts any time. // boundary is still fixable from the event log, which accepts any time.
function renderActionHints(events) { function renderActionHints(events) {
const { state } = currentSleepState(events); const { state } = currentSleepState(events);
const { walking } = currentWalkState(events);
document.querySelectorAll("button.action").forEach(btn => { document.querySelectorAll("button.action").forEach(btn => {
const type = btn.dataset.type; const type = btn.dataset.type;
const repeat = const isWalk = type === "walk-start" || type === "walk-end";
state === "asleep" ? type === "sleep-start" // Walks are a start→end pair of their own, so they answer to the walk
// state rather than the sleep one: only the boundary that flips it can
// produce a window. Mid-walk, ending it is the obvious next tap.
const repeat = isWalk
? (walking ? type === "walk-start" : type === "walk-end")
: state === "asleep" ? type === "sleep-start"
: state === "awake" ? type === "sleep-end" : state === "awake" ? type === "sleep-end"
: false; // no sleep history yet — either boundary is a fine first event : false; // no sleep history yet — either boundary is a fine first event
const unlikely = !repeat && ( const unlikely = !repeat && (
state === "asleep" ? type !== "sleep-end" isWalk ? (!walking && state === "asleep")
: state === "asleep" ? type !== "sleep-end"
: state === "awake" ? type === "sleep-end" : state === "awake" ? type === "sleep-end"
: false // no sleep history yet — no hints to give : false // no sleep history yet — no hints to give
); );
btn.classList.toggle("unlikely", unlikely); btn.classList.toggle("unlikely", unlikely);
btn.disabled = repeat; btn.disabled = repeat;
btn.title = repeat btn.title = !repeat ? ""
? (type === "sleep-start" ? "Already asleep" : "Already awake") : isWalk ? (walking ? "Already on a walk" : "No walk in progress")
: ""; : type === "sleep-start" ? "Already asleep" : "Already awake";
}); });
} }
@@ -2201,6 +2352,7 @@
renderTiming(events); renderTiming(events);
renderSleepWindows(events); renderSleepWindows(events);
renderWakeWindows(events); renderWakeWindows(events);
renderWalks(events);
renderWeekly(events); renderWeekly(events);
renderSleepTimeline(events); renderSleepTimeline(events);
renderSleepTrend(events); renderSleepTrend(events);
@@ -4073,6 +4225,7 @@
renderTiming(evs); renderTiming(evs);
renderSleepWindows(evs); renderSleepWindows(evs);
renderWakeWindows(evs); renderWakeWindows(evs);
renderWalks(evs);
renderWeekly(evs); renderWeekly(evs);
renderSleepTimeline(evs); renderSleepTimeline(evs);
renderSleepTrend(evs); renderSleepTrend(evs);
+1
View File
@@ -1,4 +1,5 @@
[ [
{ "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 🍽️ 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-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": "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": "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" },
+25 -2
View File
@@ -111,14 +111,18 @@
<!-- Explicit rows, grouped by what the buttons mean, rather than one <!-- 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 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 width (a flat grid split them apart at some column counts, and
orphaned the last button on a row of its own). Row 1 is the timed orphaned the last button on a row of its own). A row per timed
pair, row 2 the one-tap moments, row 3 the two that open a dialog pair, then the one-tap moments, then the two that open a dialog
to type a value. --> to type a value. -->
<div class="actions"> <div class="actions">
<div class="action-row"> <div class="action-row">
<button class="action sleep" data-type="sleep-start">😴 Sleep start</button> <button class="action sleep" data-type="sleep-start">😴 Sleep start</button>
<button class="action sleep" data-type="sleep-end">⏰ Sleep end</button> <button class="action sleep" data-type="sleep-end">⏰ Sleep end</button>
</div> </div>
<div class="action-row">
<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"> <div class="action-row three-up">
<button class="action eat" data-type="eat">🍽️ Ate</button> <button class="action eat" data-type="eat">🍽️ Ate</button>
<button class="action pee" data-type="pee">💧 Pee</button> <button class="action pee" data-type="pee">💧 Pee</button>
@@ -154,6 +158,11 @@
<div class="stat-label">Awake</div> <div class="stat-label">Awake</div>
<div class="stat-value" id="stat-awake">0h 0m</div> <div class="stat-value" id="stat-awake">0h 0m</div>
</div> </div>
<div class="stat">
<div class="stat-label">Walks</div>
<div class="stat-value" id="stat-walk">0m</div>
<div class="stat-sub" id="stat-walk-count" hidden></div>
</div>
<div class="stat"> <div class="stat">
<div class="stat-label">Meals</div> <div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div> <div class="stat-value" id="stat-meals">0</div>
@@ -178,6 +187,7 @@
<div class="last-row"><span>Last poo</span><span id="last-poo"></span></div> <div class="last-row"><span>Last poo</span><span id="last-poo"></span></div>
<div class="last-row"><span>Last meal</span><span id="last-eat"></span></div> <div class="last-row"><span>Last meal</span><span id="last-eat"></span></div>
<div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div> <div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div>
<div class="last-row"><span>Last walk</span><span id="last-walk"></span></div>
</div> </div>
</section> </section>
@@ -221,6 +231,15 @@
<p id="wake-empty" class="empty">No wake windows yet for this day.</p> <p id="wake-empty" class="empty">No wake windows yet for this day.</p>
</section> </section>
<section class="walks" data-panel="walks">
<h2>Walks <span class="muted-note" id="walk-total"></span></h2>
<ul id="walk-list" class="wake-list"></ul>
<p id="walk-empty" class="empty">No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.</p>
<!-- 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>
</section>
<section class="weekly" data-panel="weekly"> <section class="weekly" data-panel="weekly">
<h2 id="daily-charts-title">Last 7 days</h2> <h2 id="daily-charts-title">Last 7 days</h2>
<div class="chart-days-picker" role="group" aria-label="How many days the charts cover"> <div class="chart-days-picker" role="group" aria-label="How many days the charts cover">
@@ -241,6 +260,10 @@
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label> <label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
</div> </div>
</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" id="grams-chart-wrap" hidden>
<div class="chart-title">Food (grams)</div> <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> <svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
+10
View File
@@ -11,6 +11,7 @@
--poo: #8a5a3b; --poo: #8a5a3b;
--weight: #2bb3a3; --weight: #2bb3a3;
--training: #b04ecf; --training: #b04ecf;
--walk: #3f9e63;
--note: #6f7a90; --note: #6f7a90;
--danger: #d64545; --danger: #d64545;
--gain: #2e9e5b; --gain: #2e9e5b;
@@ -283,6 +284,7 @@ button.action.eat { background: var(--eat); }
button.action.pee { background: var(--pee); color: #2b240a; } button.action.pee { background: var(--pee); color: #2b240a; }
button.action.poo { background: var(--poo); } button.action.poo { background: var(--poo); }
button.action.weight { background: var(--weight); } button.action.weight { background: var(--weight); }
button.action.walk { background: var(--walk); }
button.action.note { background: var(--note); } button.action.note { background: var(--note); }
/* Unlikely given the current sleep state (see renderActionHints) dimmed /* Unlikely given the current sleep state (see renderActionHints) dimmed
but fully tappable, so corrections are never blocked. */ but fully tappable, so corrections are never blocked. */
@@ -471,6 +473,11 @@ textarea { resize: vertical; }
background: color-mix(in srgb, var(--sleep) 14%, var(--surface)); background: color-mix(in srgb, var(--sleep) 14%, var(--surface));
} }
.ww.sleep-ww.ongoing .ww-tag { color: var(--sleep); } .ww.sleep-ww.ongoing .ww-tag { color: var(--sleep); }
.ww.walk-ww.ongoing {
border-color: var(--walk);
background: color-mix(in srgb, var(--walk) 14%, var(--surface));
}
.ww.walk-ww.ongoing .ww-tag { color: var(--walk); }
.event { .event {
display: flex; display: flex;
@@ -496,6 +503,8 @@ textarea { resize: vertical; }
.event[data-type="poo"] .dot { background: var(--poo); } .event[data-type="poo"] .dot { background: var(--poo); }
.event[data-type="weight"] .dot { background: var(--weight); } .event[data-type="weight"] .dot { background: var(--weight); }
.event[data-type="training"] .dot { background: var(--training); } .event[data-type="training"] .dot { background: var(--training); }
.event[data-type="walk-start"] .dot,
.event[data-type="walk-end"] .dot { background: var(--walk); }
.event[data-type="note"] .dot { background: var(--note); } .event[data-type="note"] .dot { background: var(--note); }
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; } .event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
@@ -694,6 +703,7 @@ dialog menu {
} }
.chart-svg text.day-label-sel { fill: var(--accent); font-weight: 600; } .chart-svg text.day-label-sel { fill: var(--accent); font-weight: 600; }
.chart-svg .bar-sleep { fill: var(--sleep); } .chart-svg .bar-sleep { fill: var(--sleep); }
.chart-svg .bar-walk { fill: var(--walk); }
.chart-svg .bar-pee { fill: var(--pee); } .chart-svg .bar-pee { fill: var(--pee); }
.chart-svg .bar-poo { fill: var(--poo); } .chart-svg .bar-poo { fill: var(--poo); }
.chart-svg .bar-eat { fill: var(--eat); } .chart-svg .bar-eat { fill: var(--eat); }