Compare commits
3
Commits
17ce68da08
...
d551bb96eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d551bb96eb | ||
|
|
f162ac5732 | ||
|
|
ead575df2c |
@@ -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.
|
||||
|
||||
|
||||
+247
-53
@@ -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,8 +1035,72 @@
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Which history rows sit inside a sleep or walk window, so the list can run a
|
||||
// dotted rail down the margin from a start row to its end row. It reuses the
|
||||
// windows the Sleep and Walks panels already draw, so a pair that crosses
|
||||
// midnight is treated the same way here as there. Rows logged in between — a
|
||||
// pee taken on a walk — fall inside the bracket, which is the point: the rail
|
||||
// says "this happened during that", not merely "these two are a pair".
|
||||
//
|
||||
// Roles are named for where the row sits in the *rendered* list, which runs
|
||||
// newest-first, so a span's chronologically last event is its top row. A span
|
||||
// whose boundary isn't itself a row here (an ongoing walk, or one that runs
|
||||
// past midnight) leaves that end open, and the rail runs off the list edge
|
||||
// rather than stopping at a row that didn't end anything.
|
||||
function historyRails(events, dayEvents, day) {
|
||||
const rails = new Map();
|
||||
const spans = [
|
||||
...sleepWindowsForDay(events, day).map(w => ({ ...w, kind: "sleep" })),
|
||||
...walkWindowsForDay(events, day).map(w => ({ ...w, kind: "walk" })),
|
||||
];
|
||||
for (const span of spans) {
|
||||
const inside = dayEvents.filter(e => e.at >= span.start && e.at <= span.end);
|
||||
if (inside.length < 2) continue; // nothing to tie to
|
||||
const closedTop = inside[inside.length - 1].at >= span.end;
|
||||
const closedBottom = inside[0].at <= span.start;
|
||||
inside.forEach((e, i) => {
|
||||
const role =
|
||||
i === inside.length - 1 ? (closedTop ? "top" : "mid")
|
||||
: i === 0 ? (closedBottom ? "bottom" : "mid")
|
||||
: "mid";
|
||||
// Sleep is added first, so the (in practice impossible) overlap of a
|
||||
// walk and a sleep paints as the walk.
|
||||
rails.set(e.id, { kind: span.kind, role });
|
||||
});
|
||||
}
|
||||
return rails;
|
||||
}
|
||||
|
||||
function renderHistory(events) {
|
||||
const dayEvents = eventsForDay(events, selectedDay()).reverse();
|
||||
const day = selectedDay();
|
||||
const chronological = eventsForDay(events, day);
|
||||
const rails = historyRails(events, chronological, day);
|
||||
const dayEvents = [...chronological].reverse();
|
||||
const exNames = exerciseNames();
|
||||
eventList.innerHTML = "";
|
||||
if (dayEvents.length === 0) {
|
||||
@@ -991,6 +1117,8 @@
|
||||
li.className = "event";
|
||||
li.dataset.type = ev.type;
|
||||
li.dataset.id = ev.id;
|
||||
const rail = rails.get(ev.id);
|
||||
if (rail) li.classList.add("rail", `rail-${rail.kind}`, `rail-${rail.role}`);
|
||||
li.innerHTML = `
|
||||
<span class="dot"></span>
|
||||
<span class="time">${formatTime(ev.at)}</span>
|
||||
@@ -1166,6 +1294,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 +1329,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 +1496,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 +1537,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 +2346,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 +2393,7 @@
|
||||
renderTiming(events);
|
||||
renderSleepWindows(events);
|
||||
renderWakeWindows(events);
|
||||
renderWalks(events);
|
||||
renderWeekly(events);
|
||||
renderSleepTimeline(events);
|
||||
renderSleepTrend(events);
|
||||
@@ -4073,6 +4266,7 @@
|
||||
renderTiming(evs);
|
||||
renderSleepWindows(evs);
|
||||
renderWakeWindows(evs);
|
||||
renderWalks(evs);
|
||||
renderWeekly(evs);
|
||||
renderSleepTimeline(evs);
|
||||
renderSleepTrend(evs);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
[
|
||||
{ "date": "2026-08-31", "text": "A sleep or walk pair is now tied together in the History log by a dotted rail down the side, in that pair's colour, running from the start row to the end row. Anything logged in between sits inside the bracket, so a pee taken on a walk reads as having happened during it. A pair still in progress, or one that ran over from yesterday, leaves its end of the rail open" },
|
||||
{ "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" },
|
||||
{ "date": "2026-08-24", "text": "The timing panel now covers meals too — typical and shortest time between them, alongside the pee and poo gaps — so you can see the feeding rhythm the same way. It is titled just “Timing” now that it is no longer only about bathroom breaks" },
|
||||
|
||||
+43
-8
@@ -108,14 +108,30 @@
|
||||
|
||||
<section class="quick-actions">
|
||||
<h2>Log event</h2>
|
||||
<div class="grid">
|
||||
<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 eat" data-type="eat">🍽️ Ate</button>
|
||||
<button class="action pee" data-type="pee">💧 Pee</button>
|
||||
<button class="action poo" data-type="poo">💩 Poo</button>
|
||||
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
|
||||
<button class="action note" data-type="note">📝 Note</button>
|
||||
<!-- 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). 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>
|
||||
<button class="action poo" data-type="poo">💩 Poo</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
|
||||
<button class="action note" data-type="note">📝 Note</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -142,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>
|
||||
@@ -166,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>
|
||||
|
||||
@@ -209,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">
|
||||
@@ -229,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>
|
||||
|
||||
+44
-3
@@ -11,6 +11,7 @@
|
||||
--poo: #8a5a3b;
|
||||
--weight: #2bb3a3;
|
||||
--training: #b04ecf;
|
||||
--walk: #3f9e63;
|
||||
--note: #6f7a90;
|
||||
--danger: #d64545;
|
||||
--gain: #2e9e5b;
|
||||
@@ -183,6 +184,9 @@ body::before {
|
||||
}
|
||||
/* Keep the single row intact on narrow phones. */
|
||||
@media (max-width: 370px) {
|
||||
/* Three columns leave ~55px of text room on the narrowest phones; trimming
|
||||
the side padding keeps "🍽️ Ate" on one line instead of wrapping. */
|
||||
.action-row.three-up button { padding-left: 6px; padding-right: 6px; }
|
||||
.day-bar { gap: 4px; }
|
||||
.day-bar button:not(.bar-clock) { padding: 8px 7px; }
|
||||
.bar-clock { font-size: 0.9rem; padding: 6px 8px; gap: 5px; }
|
||||
@@ -242,11 +246,20 @@ body::before {
|
||||
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--accent) 10%, var(--surface))); }
|
||||
.big-clock.awake .bc-time { color: var(--accent); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
/* Quick actions: a stack of explicit rows (see index.html) instead of one
|
||||
auto-fit grid, so the grouping is the same at every width. Equal columns
|
||||
within a row, two by default and three for the short-labelled moments. */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.action-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.action-row.three-up { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
@@ -271,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. */
|
||||
@@ -459,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;
|
||||
@@ -477,6 +496,25 @@ textarea { resize: vertical; }
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Rail bracketing a sleep or walk pair in the history log (see historyRails).
|
||||
It is drawn in the section's own padding, left of the cards, so tying rows
|
||||
together costs no layout: nothing indents and no row moves. The anchored end
|
||||
stops at the row's middle, level with its dot; the other ends overshoot the
|
||||
6px list gap by 4px each so consecutive segments overlap into one line. */
|
||||
.event.rail { position: relative; }
|
||||
.event.rail::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
width: 0;
|
||||
border-left: 2px dotted var(--rail, var(--border));
|
||||
}
|
||||
.event.rail-top::before { top: 50%; bottom: -4px; }
|
||||
.event.rail-mid::before { top: -4px; bottom: -4px; }
|
||||
.event.rail-bottom::before { top: -4px; bottom: 50%; }
|
||||
.event.rail-sleep { --rail: color-mix(in srgb, var(--sleep) 70%, transparent); }
|
||||
.event.rail-walk { --rail: color-mix(in srgb, var(--walk) 70%, transparent); }
|
||||
.event[data-type="sleep-start"] .dot,
|
||||
.event[data-type="sleep-end"] .dot { background: var(--sleep); }
|
||||
.event[data-type="eat"] .dot { background: var(--eat); }
|
||||
@@ -484,6 +522,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; }
|
||||
@@ -682,6 +722,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