diff --git a/README.md b/README.md index 1b03b6f..b10f740 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,33 @@ source-of-truth and sync between devices. A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` / `sync error` / `offline`. Tap it to force-sync. +## On screen + +The panels are grouped into five tabs — **Today**, **Sleep**, **Walks**, +**Habits** (pee/poo/meal timing and counts) and **Growth** (weight, training, +notes) — so each screen holds one subject instead of all fourteen panels in one +column. The day bar and the log buttons sit above the tabs and stay put on all +of them, because logging has to be one tap from wherever you are. + +- Each tab is a `.tab-panel` wrapper around the existing sections. The + **wrapper** is what gets hidden, never the sections: `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, including the tabs you + can't see. Nothing measures layout — the charts scale through their `viewBox` + — so drawing into a hidden wrapper is safe, and it means a tab is never + briefly stale when you arrive on it. +- Tapping a panel's heading still folds it away, remembered across reloads, and + composes with tabs: tabs group, folding tunes what shows within a group. The + chosen tab is remembered the same way (device-global, like the theme). +- **Back returns to Today**, from any tab, in one press; a second press leaves + the app. Exactly one history entry is ever live — armed on leaving Today and + spent on returning, whether that return came from the back button or from + tapping the tab. An entry per switch is what a browser does unaided, and is + why tabbed apps get a reputation for trapping you: flick between tabs fifteen + times and it takes fifteen presses to escape. Two presses, always, from + anywhere. + ## Layout ``` diff --git a/src/app.js b/src/app.js index 7fe6cfe..291fd56 100644 --- a/src/app.js +++ b/src/app.js @@ -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", () => { diff --git a/src/changelog.json b/src/changelog.json index 53b4e19..d9eb8af 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -1,4 +1,5 @@ [ + { "date": "2026-09-07", "text": "The page is split into five tabs — Today, Sleep, Walks, Habits and Growth — instead of one long column of fourteen panels. Today has the overview, sleep & wake and the day's history; Sleep and Walks each have their day-by-day chart, their when-it-happens grid and their trend; Habits has the pee, poo and meal timing and counts; Growth has weight, training and notes. Getting to the weight curve no longer means scrolling past everything else. The day bar and the log buttons sit above the tabs and stay there whichever one you're on, so logging is still one tap from anywhere. Folding a panel by tapping its heading works exactly as before, inside its tab, and the app reopens on the tab you left it on. The back button (or the back gesture) returns you to Today from wherever you are, and pressing it again leaves the app — always two presses to get out, however much you'd been flicking between tabs beforehand" }, { "date": "2026-09-07", "text": "Guest links can be copied whenever you want. Settings → Guest access now shows each live link's URL next to it with a Copy button, instead of showing it once when you created it and never again. If you lose the message you sent, or want to pass the same link to someone else, you can just take it again rather than making a new one and leaving whoever already had the old one locked out. Links you made before this change can't be shown — only a scrambled form of those was kept — so revoke one and create a fresh one if you need its URL back" }, { "date": "2026-09-07", "text": "Fixed buttons that were meant to be hidden but showed anyway. A guest opening an entry the owner logged saw Delete and Save on it — they never worked (the server refuses the change) but they had no business being there. The same fault had been quietly affecting three other things for a while: the 🌳 pedigree button appeared before you had set a pedigree ID, “Send a test notification” appeared when reminders weren't available, and the exercise dialog offered Delete while you were adding a new exercise rather than editing one. One styling rule was overriding every one of them" }, { "date": "2026-09-07", "text": "A day can now be left out of the stats. Open the day, tap “⊘ Not counted” next to the overview heading, and it stops feeding the charts and averages — useful when someone else had the puppy and the record is thinner than the day really was, so it isn't fair to count it. Nothing is deleted or hidden: the day's own overview, history and sleep & wake list are exactly as they were, just dimmed and labelled, and you can switch it back at any time. In the day-by-day charts the day keeps its place but is drawn as a hatch instead of a bar, so a deliberate gap can't be misread as a day the puppy barely slept. Weigh-ins and notes still count wherever they fall — those are facts you recorded, not behaviour a sparse day distorts — so the weight curve and the Notes log are untouched. The Timing panel throws away gaps that reach across a skipped day rather than measuring them, which would otherwise turn two normal days into one enormous fake gap" }, diff --git a/src/index.html b/src/index.html index 80de560..046f5dc 100644 --- a/src/index.html +++ b/src/index.html @@ -106,6 +106,7 @@
+
+ + -
-

Today's overview

- -
- -
-
-
Sleep
-
0h 0m
+
+ +
+

Today's overview

+
-
-
Awake
-
0h 0m
+ +
+
+
Sleep
+
0h 0m
+
+
+
Awake
+
0h 0m
+
+
+
Walks
+
0m
+ +
+
+
Meals
+
0
+ +
+
+
Pees
+
0
+
+
+
Poos
+
0
+
+
+
Training
+
0
+
-
-
Walks
-
0m
- + +
+
Last pee
+
Last poo
+
Last meal
+
Last sleep
+
Last walk
-
-
Meals
-
0
- +
+ + +
+

Sleep & wake

+
    +

    No sleep or wake windows yet for this day.

    +
    + +
    +

    History

    +
      +

      No events logged for this day.

      +
      +
      + + + + +

      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.

      +
      + -
      -
      Last pee
      -
      Last poo
      -
      Last meal
      -
      Last sleep
      -
      Last walk
      -
      - - -
      -

      Timing (last 7 days)

      - -
      -
      -
      Pees
      - - +
      + -
      -

      Walks

      - -

      No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.

      - -
      - - - - - - -
      -

      Weight

      -
      -
      -
      Latest
      -
      +
      -
      -

      Notes

      - -

      No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.

      -
      +
      +

      Training

      + +

      No exercises yet. Add one to start tracking training.

      + + +
      -
      -

      History

      - -

      No events logged for this day.

      -
      +
      +

      Notes

      + +

      No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.

      +
      +