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:
@@ -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.
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+25
-2
@@ -111,14 +111,18 @@
|
||||
<!-- 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). Row 1 is the timed
|
||||
pair, row 2 the one-tap moments, row 3 the two that open a dialog
|
||||
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. -->
|
||||
<div class="actions">
|
||||
<div class="action-row">
|
||||
<button class="action sleep" data-type="sleep-start">😴 Sleep start</button>
|
||||
<button class="action sleep" data-type="sleep-end">⏰ Sleep end</button>
|
||||
</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">
|
||||
<button class="action eat" data-type="eat">🍽️ Ate</button>
|
||||
<button class="action pee" data-type="pee">💧 Pee</button>
|
||||
@@ -154,6 +158,11 @@
|
||||
<div class="stat-label">Awake</div>
|
||||
<div class="stat-value" id="stat-awake">0h 0m</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-label">Meals</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 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 walk</span><span id="last-walk">—</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -221,6 +231,15 @@
|
||||
<p id="wake-empty" class="empty">No wake windows yet for this day.</p>
|
||||
</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">
|
||||
<h2 id="daily-charts-title">Last 7 days</h2>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
--poo: #8a5a3b;
|
||||
--weight: #2bb3a3;
|
||||
--training: #b04ecf;
|
||||
--walk: #3f9e63;
|
||||
--note: #6f7a90;
|
||||
--danger: #d64545;
|
||||
--gain: #2e9e5b;
|
||||
@@ -283,6 +284,7 @@ button.action.eat { background: var(--eat); }
|
||||
button.action.pee { background: var(--pee); color: #2b240a; }
|
||||
button.action.poo { background: var(--poo); }
|
||||
button.action.weight { background: var(--weight); }
|
||||
button.action.walk { background: var(--walk); }
|
||||
button.action.note { background: var(--note); }
|
||||
/* Unlikely given the current sleep state (see renderActionHints) — dimmed
|
||||
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));
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
@@ -496,6 +503,8 @@ textarea { resize: vertical; }
|
||||
.event[data-type="poo"] .dot { background: var(--poo); }
|
||||
.event[data-type="weight"] .dot { background: var(--weight); }
|
||||
.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 .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 .bar-sleep { fill: var(--sleep); }
|
||||
.chart-svg .bar-walk { fill: var(--walk); }
|
||||
.chart-svg .bar-pee { fill: var(--pee); }
|
||||
.chart-svg .bar-poo { fill: var(--poo); }
|
||||
.chart-svg .bar-eat { fill: var(--eat); }
|
||||
|
||||
Reference in New Issue
Block a user