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:
+205
-52
@@ -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(`<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) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user