Split the page into five tabs

Fourteen panels sat in one column, so reaching the weight curve meant scrolling
past sleep, timing, walks and counts. The page had only grown — walks, walk
patterns, training and the excluded-day marker all landed on the same scroll —
and folding panels away, while it helps, is a per-panel fiddle you then have to
undo to look at anything.

They are grouped by subject now: Today (overview, sleep & wake, history), Sleep,
Walks, Habits (pee/poo/meal timing and counts) and Growth (weight, training,
notes). No tab holds more than three. The day bar and the log buttons stay above
them on every tab, because logging has to be one tap from wherever you are, and
the tab bar sticks under the day bar — two stacked stickies need the second's
offset to be the first's height, so that height is measured and published as
--day-bar-h rather than guessed.

What gets hidden is the wrapper, never the sections inside it. walk-timeline and
walk-trend carry their own hidden, set by renderWalkPatterns once a walk exists,
and hiding them directly would clobber it.

render() still draws every panel on every pass, hidden tabs included. Nothing
measures layout — the charts scale through their viewBox, and the one
getBoundingClientRect belongs to the timer pill — so drawing into a hidden
wrapper is safe, and a tab is never briefly stale when you arrive on it. That
pill's own check gains an explicit "is the card's tab showing": a hidden element
measures as zeroes, which gave the right answer here by coincidence rather than
by rule.

Back returns to Today from any tab in one press, and a second press leaves.
Exactly one history entry is ever live, armed on leaving Today and spent on
returning — including when the return is a tap on the Today tab, which would
otherwise strand the entry and make the next press appear to do nothing. An
entry per switch is what a browser does unaided, and is why tabbed apps get a
reputation for trapping you.

Folding is untouched and composes: tabs group, folding tunes what shows within a
group. Both selectors that reach for panels are descendant selectors, so the
extra nesting cost them nothing.

The reordering was scripted rather than done by hand — fourteen sections moving
between five wrappers is how you silently lose one — and a check now asserts
every panel sits in exactly one tab, that buttons and wrappers correspond, and
that the aria pairs are wired. The first run of that script dropped three
explanatory comments along the way, which is exactly the sort of thing it exists
to catch.
This commit is contained in:
Alexander Heldt
2026-09-07 19:55:40 +00:00
parent 66f89b35a9
commit 0dfbab82cf
5 changed files with 417 additions and 213 deletions
+112 -1
View File
@@ -995,7 +995,12 @@
if (!bigClockState || pill.hidden) return;
const card = document.getElementById("big-clock");
const bar = document.querySelector(".day-bar");
const cardVisible =
// The card lives on the Today tab, so on any other tab it isn't on screen
// at all and the pill is the only timer there is. Asked explicitly rather
// than left to the rect: a hidden element measures as zeroes, which would
// give the right answer here by coincidence rather than by rule.
const cardOnThisTab = card.offsetParent !== null;
const cardVisible = cardOnThisTab &&
card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom;
pill.classList.toggle("standby", cardVisible);
}
@@ -4680,6 +4685,112 @@
}
initPanels();
// ---------- tabs ----------
// Fourteen panels in one column meant scrolling past sleep, timing and walks
// to reach the weight curve. They are grouped into five tabs by subject; the
// day bar and the log buttons stay above them, so logging is one tap from
// wherever you are. Folding still works inside a tab — the two compose.
//
// The wrapper is what gets hidden, never the sections inside it: walk-timeline
// and walk-trend carry their own `hidden` (renderWalkPatterns), and hiding
// them directly would clobber that.
const TAB_KEY = "puppy-tracker:tab:v1"; // device-global, like the fold state
const DEFAULT_TAB = "today";
const tabButtons = [...document.querySelectorAll(".tabs .tab")];
const tabPanels = [...document.querySelectorAll("main .tab-panel")];
// Today is the one tab with no charts, so the window picker doesn't belong
// over it.
const TABS_WITHOUT_CHARTS = new Set(["today"]);
let activeTab = DEFAULT_TAB;
function loadTab() {
try {
const t = localStorage.getItem(TAB_KEY);
return tabButtons.some(b => b.dataset.tab === t) ? t : DEFAULT_TAB;
} catch { return DEFAULT_TAB; }
}
// Back returns to Today from any tab in one press, and a second press leaves
// the app — two presses to get out from anywhere, whatever route you took.
//
// The alternative, a history entry per switch, is what a browser would do by
// itself and is the reason tabbed apps get a reputation for trapping you:
// flick between tabs fifteen times and it takes fifteen presses to escape.
// So exactly one entry is ever live. It is armed on leaving Today and
// consumed on returning, whether that return came from the back button or
// from tapping the tab.
let backArmed = false;
function armBack() {
if (backArmed) return;
history.pushState({ puppyTab: true }, "");
backArmed = true;
}
addEventListener("popstate", () => {
// Not ours: some other navigation, so stay out of its way.
if (!backArmed) return;
backArmed = false;
showTab(DEFAULT_TAB, { manageBack: false });
});
function showTab(id, { scroll = true, manageBack = true } = {}) {
if (!tabButtons.some(b => b.dataset.tab === id)) id = DEFAULT_TAB;
activeTab = id;
for (const b of tabButtons) {
const on = b.dataset.tab === id;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", String(on));
// Roving tabindex: the tablist is one stop, arrows move within it.
b.tabIndex = on ? 0 : -1;
}
for (const p of tabPanels) p.hidden = p.dataset.tab !== id;
document.querySelector(".chart-window").hidden = TABS_WITHOUT_CHARTS.has(id);
// Arriving halfway down a different tab is disorienting.
if (scroll) window.scrollTo({ top: 0 });
try { localStorage.setItem(TAB_KEY, id); } catch { /* ignore */ }
if (manageBack) {
if (id !== DEFAULT_TAB) {
armBack();
} else if (backArmed) {
// Tapping Today gets to the same place the back button would, so spend
// the entry rather than leaving a stale one that makes the next press
// appear to do nothing. Clearing the flag first stops the popstate
// this triggers from running the handler above a second time.
backArmed = false;
history.back();
}
}
// The pill's visibility depends on whether the big card is on screen, and
// the card only exists on one tab.
updateBarClockMode();
}
for (const [i, btn] of tabButtons.entries()) {
btn.addEventListener("click", () => showTab(btn.dataset.tab));
btn.addEventListener("keydown", (e) => {
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
e.preventDefault();
const next = tabButtons[(i + step + tabButtons.length) % tabButtons.length];
showTab(next.dataset.tab, { scroll: false });
next.focus();
});
}
// The tab bar sticks directly under the day bar, which means its `top` has to
// be that bar's height — measured, because the bar's contents (and so its
// height) differ between phones and orientations.
function measureDayBar() {
const bar = document.querySelector(".day-bar");
if (!bar) return;
document.documentElement.style.setProperty("--day-bar-h", `${bar.offsetHeight}px`);
}
addEventListener("resize", measureDayBar);
measureDayBar();
showTab(loadTab(), { scroll: false });
// ---------- wiring ----------
document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => {