Compare commits

...
2 Commits
Author SHA1 Message Date
Alexander Heldt 02be8ce3e7 Interleave the sleep and wake lists, move walks down
Sleep windows and Wake windows were two panels showing two halves of one
sequence: a wake window is precisely the gap between two sleeps, so the panels
alternated with each other and neither could be read as the day's rhythm on its
own. Answering "how long was he up before that nap?" meant looking at one panel,
holding a time in your head, and finding it in the other.

They are one list now, the two window sets sorted together by start time, which
is all the interleaving that alternating-by-construction needs. Each row states
whether it is asleep or awake and carries a stripe in its colour, so the
alternation reads before the words do, and the row still running keeps the
highlight it had. renderWindowList grew the ability for a window to bring its
own class and label; the single-kind lists still pass one of each for the whole
list and tag only the open row, where "Walking" is news rather than a repeat of
the heading it sits under.

One rule had to be spelled out: the new per-kind stripe ties on specificity with
the generic .ww.ongoing, and being later would have won, leaving an open awake
row with a faded edge instead of its full-strength one.

The walks group — the panel and both its patterns — moves below "Pees, poos &
meals". The three stay together: they were grouped under Walks deliberately, and
leaving the patterns behind would strand two walk charts among the sleep ones.

Both old panel keys go stale, so anyone who had folded Sleep windows or Wake
windows gets the merged panel open, as with any panel that is new to a build.
2026-08-31 22:21:43 +00:00
Alexander Heldt 50d0d8a726 Let the weigh-in list fold on its own
The Weight panel holds three things, and only one of them grows: the latest
figure and the curve stay one screen forever, while the row list gains a line
every weigh-in until it pushes everything else off. Folding the panel to get rid
of it also takes away the two parts worth keeping.

So the fold mechanism now works on any keyed element inside main, not only on a
section, and the list moves into a block that folds by its own h3 while the
panel keeps folding by its h2. The two nest without special handling — separate
keys, and a folded section hides the block along with the rest, so the block's
own state is simply there again when the section reopens.

The CSS generalises from section.collapsible to a class, but scoped to main:
the pedigree tree uses .collapsed for its own branches, and an unscoped
selector would have folded every card in it.
2026-08-31 22:11:27 +00:00
4 changed files with 149 additions and 91 deletions
+48 -34
View File
@@ -999,8 +999,14 @@
} }
empty.hidden = true; empty.hidden = true;
for (const w of windows) { for (const w of windows) {
// A window may bring its own class and label, which is how the merged
// sleep/wake list states every row's kind. A single-kind list passes one
// class and one label for the lot and only tags the open row, where
// "Walking" is news rather than a repetition of the heading.
const cls = w.cls || extraClass;
const tag = w.tag || (w.ongoing ? ongoingLabel : "");
const li = document.createElement("li"); const li = document.createElement("li");
li.className = `ww ${extraClass}` + (w.ongoing ? " ongoing" : ""); li.className = `ww ${cls}` + (w.ongoing ? " ongoing" : "");
const range = document.createElement("span"); const range = document.createElement("span");
range.className = "ww-range"; range.className = "ww-range";
range.textContent = w.ongoing range.textContent = w.ongoing
@@ -1011,29 +1017,34 @@
dur.textContent = formatDuration(w.end - w.start); dur.textContent = formatDuration(w.end - w.start);
li.appendChild(range); li.appendChild(range);
li.appendChild(dur); li.appendChild(dur);
if (w.ongoing) { if (tag) {
const tag = document.createElement("span"); const tagEl = document.createElement("span");
tag.className = "ww-tag"; tagEl.className = "ww-tag";
tag.textContent = ongoingLabel; tagEl.textContent = tag;
li.appendChild(tag); li.appendChild(tagEl);
} }
list.appendChild(li); list.appendChild(li);
} }
} }
function renderSleepWindows(events) { // Sleep and wake windows are the same boundaries read two ways — a wake
renderWindowList( // window is precisely the gap between two sleeps — so they alternate by
"sleep-list", "sleep-empty", // construction and sorting the two sets by start time interleaves them back
sleepWindowsForDay(events, selectedDay()), // into the day's actual rhythm. Read in order, "45 minutes asleep after two
"Asleep", "sleep-ww", // and a half hours up" is a single glance instead of a comparison between
); // two lists.
function sleepWakeWindowsForDay(events, day) {
return [
...sleepWindowsForDay(events, day).map(w => ({ ...w, cls: "sleep-ww", tag: "Asleep" })),
...wakeWindowsForDay(events, day).map(w => ({ ...w, cls: "wake-ww", tag: "Awake" })),
].sort((a, b) => a.start - b.start);
} }
function renderWakeWindows(events) { function renderSleepWake(events) {
renderWindowList( renderWindowList(
"wake-list", "wake-empty", "sleep-wake-list", "sleep-wake-empty",
wakeWindowsForDay(events, selectedDay()), sleepWakeWindowsForDay(events, selectedDay()),
"Awake", "wake-ww", "", "",
); );
} }
@@ -2595,8 +2606,7 @@
renderStats(events); renderStats(events);
renderLasts(events); renderLasts(events);
renderTiming(events); renderTiming(events);
renderSleepWindows(events); renderSleepWake(events);
renderWakeWindows(events);
renderWalks(events); renderWalks(events);
renderWeekly(events); renderWeekly(events);
renderSleepTimeline(events); renderSleepTimeline(events);
@@ -4193,10 +4203,15 @@
}); });
// ---------- collapsible panels ---------- // ---------- collapsible panels ----------
// Each main section carrying a data-panel key can be folded by clicking its // Anything in main carrying a data-panel key can be folded by clicking its
// heading; collapsed keys are remembered across reloads. State is device-global // heading; collapsed keys are remembered across reloads. State is device-global
// (like the theme), so a single non-namespaced key is fine — it's not per-user // (like the theme), so a single non-namespaced key is fine — it's not per-user
// data. Only collapsed panels are stored, so newly added panels default open. // data. Only collapsed panels are stored, so newly added panels default open.
//
// A whole section heads with an h2, a foldable block inside one with an h3.
// The two nest without special handling: each has its own key, and a folded
// section hides the block along with everything else it holds, which leaves
// the block's own state to reappear as it was when the section reopens.
const PANELS_KEY = "puppy-tracker:panels:v1"; const PANELS_KEY = "puppy-tracker:panels:v1";
function loadPanelState() { function loadPanelState() {
try { try {
@@ -4209,25 +4224,25 @@
} }
function initPanels() { function initPanels() {
const state = loadPanelState(); const state = loadPanelState();
document.querySelectorAll("main section[data-panel]").forEach(section => { document.querySelectorAll("main [data-panel]").forEach(panel => {
const key = section.dataset.panel; const key = panel.dataset.panel;
const h2 = section.querySelector(":scope > h2"); const head = panel.querySelector(":scope > h2, :scope > h3");
if (!h2) return; if (!head) return;
section.classList.add("collapsible"); panel.classList.add("collapsible");
const collapsed = !!state[key]; const collapsed = !!state[key];
section.classList.toggle("collapsed", collapsed); panel.classList.toggle("collapsed", collapsed);
h2.setAttribute("role", "button"); head.setAttribute("role", "button");
h2.setAttribute("tabindex", "0"); head.setAttribute("tabindex", "0");
h2.setAttribute("aria-expanded", String(!collapsed)); head.setAttribute("aria-expanded", String(!collapsed));
const toggle = () => { const toggle = () => {
const nowCollapsed = section.classList.toggle("collapsed"); const nowCollapsed = panel.classList.toggle("collapsed");
h2.setAttribute("aria-expanded", String(!nowCollapsed)); head.setAttribute("aria-expanded", String(!nowCollapsed));
const s = loadPanelState(); const s = loadPanelState();
if (nowCollapsed) s[key] = true; else delete s[key]; if (nowCollapsed) s[key] = true; else delete s[key];
savePanelState(s); savePanelState(s);
}; };
h2.addEventListener("click", toggle); head.addEventListener("click", toggle);
h2.addEventListener("keydown", (e) => { head.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
}); });
}); });
@@ -4469,8 +4484,7 @@
renderStats(evs); renderStats(evs);
renderLasts(evs); renderLasts(evs);
renderTiming(evs); renderTiming(evs);
renderSleepWindows(evs); renderSleepWake(evs);
renderWakeWindows(evs);
renderWalks(evs); renderWalks(evs);
renderWeekly(evs); renderWeekly(evs);
renderSleepTimeline(evs); renderSleepTimeline(evs);
+3
View File
@@ -1,4 +1,7 @@
[ [
{ "date": "2026-08-31", "text": "The Walks panel and its two charts have moved down the page, below “Pees, poos & meals”" },
{ "date": "2026-08-31", "text": "Sleep windows and Wake windows are one “Sleep & wake” panel now, with the two interleaved into a single list in time order — asleep 22:1006:05, awake until 08:30, asleep 45 min, and so on down the day. A wake window is just the gap between two sleeps, so the two lists were always halves of the same sequence, and reading them in order makes “only a 45-minute nap after two and a half hours up” obvious in a way two separate lists never did. Each row says which it is and carries a stripe in its colour; whichever one is still running keeps the highlight" },
{ "date": "2026-08-31", "text": "The list of weigh-ins under the Weight chart folds away on its own now — tap “History” inside the panel to collapse just the rows and keep the latest figure and the curve on screen. It remembers the choice like the panels do, and folding the whole Weight panel still takes everything with it" },
{ "date": "2026-08-31", "text": "Walks now get the same two pattern views sleep has. “When … walks” is a day-per-row grid shaded where a walk was on, so you can see at a glance whether the routine is actually regular or drifts around. “Walk trend” draws the minutes walked so far at each point of the day against yesterday and the average over the picked window, with the age-based goal as a line and a ✓ once the day clears it — the line climbs only while a walk is on, so every step is one walk. Both appear under the Walks panel as soon as you have logged a walk, and stay out of the way until then" }, { "date": "2026-08-31", "text": "Walks now get the same two pattern views sleep has. “When … walks” is a day-per-row grid shaded where a walk was on, so you can see at a glance whether the routine is actually regular or drifts around. “Walk trend” draws the minutes walked so far at each point of the day against yesterday and the average over the picked window, with the age-based goal as a line and a ✓ once the day clears it — the line climbs only while a walk is on, so every step is one walk. Both appear under the Walks panel as soon as you have logged a walk, and stay out of the way until then" },
{ "date": "2026-08-31", "text": "The 7d / 14d / 30d buttons now set the window for the Timing panel too. Until now the typical, shortest and longest gaps between pees, poos and meals were always measured over the last 7 days whatever you picked; switch to 30d and they are measured over 30, which settles down the typical gap once there is a month of history to draw on" }, { "date": "2026-08-31", "text": "The 7d / 14d / 30d buttons now set the window for the Timing panel too. Until now the typical, shortest and longest gaps between pees, poos and meals were always measured over the last 7 days whatever you picked; switch to 30d and they are measured over 30, which settles down the typical gap once there is a month of history to draw on" },
{ "date": "2026-08-31", "text": "The charts are grouped by subject instead of all sharing one “Last 7 days” panel. Sleep hours per day is now its own Sleep panel, sitting just above the “When … sleeps” timeline with the rest of the sleep views. Daily counts and Food have joined the by-hour chart in one “Pees, poos & meals” panel, so how many a day, how much food went with them and what hours they fall in read together. Minutes walked per day has moved into the Walks panel. The 7d / 14d / 30d picker now sits in the Sleep panel and still sets the window for every one of these charts" }, { "date": "2026-08-31", "text": "The charts are grouped by subject instead of all sharing one “Last 7 days” panel. Sleep hours per day is now its own Sleep panel, sitting just above the “When … sleeps” timeline with the rest of the sleep views. Daily counts and Food have joined the by-hour chart in one “Pees, poos & meals” panel, so how many a day, how much food went with them and what hours they fall in read together. Minutes walked per day has moved into the Walks panel. The 7d / 14d / 30d picker now sits in the Sleep panel and still sets the window for every one of these charts" },
+55 -50
View File
@@ -218,56 +218,17 @@
<p class="muted-note timing-hint" id="timing-hint"></p> <p class="muted-note timing-hint" id="timing-hint"></p>
</section> </section>
<section class="sleep" data-panel="sleep-windows"> <!-- Sleep and wake windows are the same boundaries read two ways — a wake
<h2>Sleep windows</h2> window is exactly the gap between two sleeps — so they interleave into
<ul id="sleep-list" class="wake-list"></ul> one alternating list rather than sitting in two panels that each show
<p id="sleep-empty" class="empty">No sleep windows yet for this day.</p> half the day. Every row carries its state; the open one keeps the
highlight. -->
<section class="sleepwake" data-panel="sleep-wake">
<h2>Sleep &amp; wake</h2>
<ul id="sleep-wake-list" class="wake-list"></ul>
<p id="sleep-wake-empty" class="empty">No sleep or wake windows yet for this day.</p>
</section> </section>
<section class="wake" data-panel="wake-windows">
<h2>Wake windows</h2>
<ul id="wake-list" class="wake-list"></ul>
<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>
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
</div>
</section>
<!-- The two walk patterns mirror the sleep ones below, drawn from walk
windows instead of sleep windows. Both stay hidden until there is a
walk to draw, so they cost nothing to anyone not tracking walks. -->
<section class="patterns" data-panel="walk-timeline" hidden>
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="walk-trend" hidden>
<h2>Walk trend</h2>
<svg id="chart-walk-trend" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Cumulative minutes walked through the selected day, the day before it, the recent average, and the age-based daily goal"></svg>
<div class="legend">
<span class="lg wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
<span class="lg wtrend-yesterday" id="legend-wtrend-yesterday"><span class="sw"></span><span id="legend-wtrend-yesterday-text">Yesterday</span></span>
<span class="lg wtrend-avg" id="legend-wtrend-avg"><span class="sw"></span><span id="legend-wtrend-avg-text">7-day avg</span></span>
<span class="lg wtrend-goal" id="legend-wtrend-goal" hidden><span class="sw"></span><span id="legend-wtrend-goal-text">Goal</span></span>
</div>
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
</section>
<!-- The day-window picker lives in this panel but governs every
day-window chart on the page — the training grid above, both sleep
patterns below, the counts panel and the walk chart — so changing it
here changes all of them (see renderChartWindow). -->
<section class="patterns" data-panel="sleep-daily"> <section class="patterns" data-panel="sleep-daily">
<h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2> <h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart-days-picker" role="group" aria-label="How many days the charts cover"> <div class="chart-days-picker" role="group" aria-label="How many days the charts cover">
@@ -325,6 +286,44 @@
</div> </div>
</section> </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>
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
</div>
</section>
<!-- The two walk patterns mirror the sleep ones below, drawn from walk
windows instead of sleep windows. Both stay hidden until there is a
walk to draw, so they cost nothing to anyone not tracking walks. -->
<section class="patterns" data-panel="walk-timeline" hidden>
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="walk-trend" hidden>
<h2>Walk trend</h2>
<svg id="chart-walk-trend" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Cumulative minutes walked through the selected day, the day before it, the recent average, and the age-based daily goal"></svg>
<div class="legend">
<span class="lg wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
<span class="lg wtrend-yesterday" id="legend-wtrend-yesterday"><span class="sw"></span><span id="legend-wtrend-yesterday-text">Yesterday</span></span>
<span class="lg wtrend-avg" id="legend-wtrend-avg"><span class="sw"></span><span id="legend-wtrend-avg-text">7-day avg</span></span>
<span class="lg wtrend-goal" id="legend-wtrend-goal" hidden><span class="sw"></span><span id="legend-wtrend-goal-text">Goal</span></span>
</div>
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
</section>
<!-- The day-window picker lives in this panel but governs every
day-window chart on the page — the training grid above, both sleep
patterns below, the counts panel and the walk chart — so changing it
here changes all of them (see renderChartWindow). -->
<section class="weight" data-panel="weight"> <section class="weight" data-panel="weight">
<h2>Weight</h2> <h2>Weight</h2>
<div class="weight-summary"> <div class="weight-summary">
@@ -342,8 +341,14 @@
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg> <svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
<p id="weight-point-info" class="muted-note weight-point-info"></p> <p id="weight-point-info" class="muted-note weight-point-info"></p>
</div> </div>
<ul id="weight-list" class="wake-list"></ul> <!-- Folds on its own h3, independently of the Weight panel around it:
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p> the row list grows by one every weigh-in, and the summary and the
curve above it are what you usually want left on screen. -->
<div class="subpanel" data-panel="weight-history">
<h3>History</h3>
<ul id="weight-list" class="wake-list"></ul>
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</div>
</section> </section>
<section class="notes-log" data-panel="notes"> <section class="notes-log" data-panel="notes">
+43 -7
View File
@@ -462,18 +462,36 @@ textarea { resize: vertical; }
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-soft); background: var(--accent-soft);
} }
.ww.ongoing .ww-tag { /* Every row can carry a state, not only the open one: the merged sleep/wake
list labels each row asleep or awake, where the label is the whole point. */
.ww .ww-tag {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--accent);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--muted);
} }
/* A stripe in the row's own colour, so an alternating list reads as alternating
before you get to the words. */
.ww.sleep-ww { border-left: 3px solid color-mix(in srgb, var(--sleep) 55%, transparent); }
.ww.wake-ww { border-left: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); }
.ww.walk-ww { border-left: 3px solid color-mix(in srgb, var(--walk) 55%, transparent); }
.ww.sleep-ww .ww-tag { color: var(--sleep); }
.ww.wake-ww .ww-tag { color: var(--accent); }
.ww.walk-ww .ww-tag { color: var(--walk); }
.ww.ongoing .ww-tag { color: var(--accent); }
.ww.sleep-ww.ongoing { .ww.sleep-ww.ongoing {
border-color: var(--sleep); border-color: var(--sleep);
background: color-mix(in srgb, var(--sleep) 14%, var(--surface)); background: color-mix(in srgb, var(--sleep) 14%, var(--surface));
} }
.ww.sleep-ww.ongoing .ww-tag { color: var(--sleep); } .ww.sleep-ww.ongoing .ww-tag { color: var(--sleep); }
/* Spelled out like the other two: the faded stripe above would otherwise win
over the generic .ww.ongoing on specificity ties and leave the open awake
row without its full-strength edge. */
.ww.wake-ww.ongoing {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
}
.ww.walk-ww.ongoing { .ww.walk-ww.ongoing {
border-color: var(--walk); border-color: var(--walk);
background: color-mix(in srgb, var(--walk) 14%, var(--surface)); background: color-mix(in srgb, var(--walk) 14%, var(--surface));
@@ -1207,14 +1225,19 @@ button.ex-edit { padding: 6px 12px; flex-shrink: 0; }
.walk-chart { margin-top: 16px; } .walk-chart { margin-top: 16px; }
/* ---------- collapsible panels ---------- */ /* ---------- collapsible panels ---------- */
section.collapsible > h2 { /* A panel folds by its own heading: h2 for a whole section, h3 for a block
inside one. Scoped to main on purpose the pedigree tree uses .collapsed
for its own branches, and a bare selector here would fold those too. */
main .collapsible > h2,
main .collapsible > h3 {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
padding-right: 20px; padding-right: 20px;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
section.collapsible > h2::after { main .collapsible > h2::after,
main .collapsible > h3::after {
content: "▾"; content: "▾";
position: absolute; position: absolute;
right: 0; right: 0;
@@ -1224,9 +1247,22 @@ section.collapsible > h2::after {
color: var(--muted); color: var(--muted);
font-size: 0.85em; font-size: 0.85em;
} }
section.collapsed > h2::after { transform: translateY(-50%) rotate(-90deg); } main .collapsed > h2::after,
section.collapsed > h2 { margin-bottom: 0; } main .collapsed > h3::after { transform: translateY(-50%) rotate(-90deg); }
section.collapsed > :not(h2) { display: none; } main .collapsed > h2,
main .collapsed > h3 { margin-bottom: 0; }
main .collapsed > :not(h2):not(h3) { display: none; }
/* Sub-heading for a foldable block inside a panel: the section h2's look, a
size down, so it reads as subordinate to it rather than as a second panel. */
.subpanel > h3 {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin: 0 0 10px;
}
/* ---------- pedigree lookup ---------- */ /* ---------- pedigree lookup ---------- */
.pedigree-screen { .pedigree-screen {