diff --git a/README.md b/README.md
index 7e3e532..580e733 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# puppy-tracker
-A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos,
-weight, and training.
+A tiny offline-first PWA for tracking your puppy's sleep, walks, meals, pees,
+poos, weight, and training.
The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices.
diff --git a/src/app.js b/src/app.js
index 68a725e..1077b7c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -16,6 +16,8 @@
const EVENT_LABELS = {
"sleep-start": "Sleep start",
"sleep-end": "Sleep end",
+ "walk-start": "Walk start",
+ "walk-end": "Walk end",
"eat": "Ate",
"pee": "Pee",
"poo": "Poo",
@@ -522,21 +524,27 @@
return total;
}
- // Current state derived from the *latest* sleep event. The single source of
- // truth for both the big clock and the "Currently" row. For two boundary
- // events sharing the same `at` (common once "now" events are minute-floored),
- // the one logged later (higher updatedAt) wins, so the tie resolves the same
- // way everywhere it's read.
- function currentSleepState(events) {
+ // The newest of a set of boundary event types. For two events sharing the
+ // same `at` (common once "now" events are minute-floored), the one logged
+ // later (higher updatedAt) wins, so the tie resolves the same way everywhere
+ // it's read.
+ function latestOfTypes(events, types) {
let latest = null;
for (const e of events) {
- if (e.type !== "sleep-start" && e.type !== "sleep-end") continue;
+ if (!types.includes(e.type)) continue;
if (!latest ||
e.at > latest.at ||
(e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) {
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 };
return {
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) {
if (ms < 0) ms = 0;
const totalSec = Math.floor(ms / 1000);
@@ -562,44 +577,55 @@
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
// sleep-end with no following sleep-start = ongoing/open wake window.
function wakeWindows(events) {
- const sorted = events
- .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;
+ return pairWindows(events, "sleep-end", "sleep-start");
}
// Sleep windows: each sleep-start → next sleep-end pair. An unmatched
// sleep-start = ongoing/open sleep window.
function sleepWindows(events) {
- const sorted = events
- .filter(e => e.type === "sleep-start" || e.type === "sleep-end")
- .sort((a, b) => a.at - b.at);
- const out = [];
- let sleeping = null;
- for (const e of sorted) {
- if (e.type === "sleep-start") {
- sleeping = e.at;
- } else if (e.type === "sleep-end" && sleeping !== null) {
- out.push({ start: sleeping, end: e.at, ongoing: false });
- sleeping = null;
- }
+ return pairWindows(events, "sleep-start", "sleep-end");
+ }
+
+ // Walks: each walk-start → next walk-end pair, ongoing while out.
+ function walkWindows(events) {
+ return pairWindows(events, "walk-start", "walk-end");
+ }
+
+ // Time spent walking inside [fromTs, toTs], clipping windows that straddle
+ // the edges — the same treatment sleepMsInRange gives sleep.
+ 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 out;
+ return total;
}
function sleepWindowsForDay(events, day) {
@@ -627,6 +653,30 @@
.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 ----------
const dayPicker = document.getElementById("day-picker");
const eventList = document.getElementById("event-list");
@@ -663,6 +713,13 @@
const gramsEl = document.getElementById("stat-meals-grams");
gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : "";
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-poos").textContent = count("poo");
document.getElementById("stat-training").textContent = count("training");
@@ -861,6 +918,11 @@
document.getElementById("last-sleep").textContent = lastSleep
? `${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
@@ -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) {
const dayEvents = eventsForDay(events, selectedDay()).reverse();
const exNames = exerciseNames();
@@ -1166,6 +1253,7 @@
grams: dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0),
+ walkMinutes: walkMsInRange(events, from, to) / 60_000,
});
}
return days;
@@ -1200,10 +1288,11 @@
return { yMax: m, steps: m / 5 };
}
- // Grams axis: 0-based with a "nice" step so tick labels stay round whatever
- // the daily totals are (tens of grams for a tiny puppy, hundreds+ later).
- // Aims for ~10 segments so day-to-day differences of a few grams show.
- function niceAxisGrams(rawMax) {
+ // Magnitude-agnostic 0-based axis with a "nice" step, so tick labels stay
+ // round whatever the daily totals are (tens of grams for a tiny puppy,
+ // hundreds+ later; minutes walked likewise). Aims for ~10 segments so small
+ // day-to-day differences still show.
+ function niceAxisLinear(rawMax) {
if (!(rawMax > 0)) return { yMax: 100, steps: 4 };
const rawStep = rawMax / 10;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
@@ -1366,7 +1455,7 @@
const innerW = W - ML - MR;
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 barW = (innerW - (days.length - 1) * gap) / days.length;
@@ -1407,10 +1496,65 @@
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(`
No wake windows yet for this day.
+No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.
+ + +