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(``); + parts.push(`${vText}`); + } + + 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(``); + } + parts.push( + `` + + `${escapeText(title)}` + ); + if (showDayLabel(i, days.length) || isSel) { + parts.push( + `` + + `${escapeText(dayLabel(d.date, isToday))}` + ); + } + }); + + setChartSVG(svg, parts); + } + function renderWeekly(events) { const days = weeklyData(events); drawSleepChart(days); drawCountsChart(days); + drawWalkChart(days); drawGramsChart(days); } @@ -2161,31 +2305,38 @@ 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 - // toward the likely next tap. The one boundary that would just repeat the - // latest sleep event (a second "sleep start" while already asleep, or a - // second "sleep end" while already awake) is disabled outright, since it - // can only produce a zero-length window. Everything else stays clickable so + // Dim the quick actions that don't fit the current state — a nudge + // toward the likely next tap. A boundary that would just repeat the latest + // one of its pair (a second "sleep start" while already asleep, a "walk end" + // with no walk running) is disabled outright, since it can only produce a + // zero-length window. Everything else stays clickable so // corrections (a mid-nap pee) are never blocked, and a genuinely missed // boundary is still fixable from the event log, which accepts any time. function renderActionHints(events) { const { state } = currentSleepState(events); + const { walking } = currentWalkState(events); document.querySelectorAll("button.action").forEach(btn => { const type = btn.dataset.type; - const repeat = - state === "asleep" ? type === "sleep-start" + const isWalk = type === "walk-start" || type === "walk-end"; + // 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" : false; // no sleep history yet — either boundary is a fine first event const unlikely = !repeat && ( - state === "asleep" ? type !== "sleep-end" + isWalk ? (!walking && state === "asleep") + : state === "asleep" ? type !== "sleep-end" : state === "awake" ? type === "sleep-end" : false // no sleep history yet — no hints to give ); btn.classList.toggle("unlikely", unlikely); btn.disabled = repeat; - btn.title = repeat - ? (type === "sleep-start" ? "Already asleep" : "Already awake") - : ""; + btn.title = !repeat ? "" + : isWalk ? (walking ? "Already on a walk" : "No walk in progress") + : type === "sleep-start" ? "Already asleep" : "Already awake"; }); } @@ -2201,6 +2352,7 @@ renderTiming(events); renderSleepWindows(events); renderWakeWindows(events); + renderWalks(events); renderWeekly(events); renderSleepTimeline(events); renderSleepTrend(events); @@ -4073,6 +4225,7 @@ renderTiming(evs); renderSleepWindows(evs); renderWakeWindows(evs); + renderWalks(evs); renderWeekly(evs); renderSleepTimeline(evs); renderSleepTrend(evs); diff --git a/src/changelog.json b/src/changelog.json index 857ae20..00c10bb 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -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-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" }, diff --git a/src/index.html b/src/index.html index ca0307b..16ca881 100644 --- a/src/index.html +++ b/src/index.html @@ -111,14 +111,18 @@
+
+ + +
@@ -154,6 +158,11 @@
Awake
0h 0m
+
+
Walks
+
0m
+ +
Meals
0
@@ -178,6 +187,7 @@
Last poo
Last meal
Last sleep
+
Last walk
@@ -221,6 +231,15 @@

No wake windows yet for this day.

+
+

Walks

+
    +

    No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.

    + + +
    +

    Last 7 days

    @@ -241,6 +260,10 @@
    +