Show a timer in the day bar while a walk is on
The bar already carries the asleep/awake counter; a walk in progress had nothing, so "how long have we been out" meant going to the Walks panel to look. It gets a second pill now, counting from the walk's start, and tapping it ends the walk — the same bargain the sleep pill offers for the sleep boundary. Two timers no longer fit beside the day controls on a narrow phone, so the bar wraps. That needed the children grouping first: with seven loose ones the break could land anywhere, and stranding "Today" alone on a second line is worse than not wrapping at all. The timers are one group and the day controls another, so the wrap falls between them. Two things follow from the bar changing height. The tab bar sticks to that height, and the ResizeObserver added when the pill first appearing had the same effect already covers it — nothing new needed. And both pills go to standby together while the big card is on screen: standby is visibility, not display, so the bar keeps its wrapped height while you scroll and the tab bar beneath it does not shuffle. The card shows the walk while there is one. It has room for a single timer, and you are necessarily awake on a walk, so "awake for 3h" is the less useful of the two readings; the bar keeps both. That also meant moving the card out of the early return for "no sleep logged yet" — a walk can be the first thing ever recorded, and the card was staying blank through it.
This commit is contained in:
@@ -51,6 +51,16 @@ notes) — so each screen holds one subject instead of all fourteen panels in on
|
|||||||
column. The day bar and the log buttons sit above the tabs and stay put on all
|
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.
|
of them, because logging has to be one tap from wherever you are.
|
||||||
|
|
||||||
|
- The day bar carries up to two timers, each of which logs the boundary that
|
||||||
|
ends what it is counting when tapped: the asleep/awake one, and — only while
|
||||||
|
a walk is running — the walk. Two of them no longer fit beside the day
|
||||||
|
controls on a narrow phone, so the bar wraps; the timers and the controls are
|
||||||
|
each a group, so it wraps between them rather than stranding "Today" on a
|
||||||
|
line of its own. The bar's height changes when it does and the tab bar sticks
|
||||||
|
to that height, which is why `--day-bar-h` is kept current by a
|
||||||
|
`ResizeObserver` rather than measured once. The big card on Today shows
|
||||||
|
whichever of the two is the more immediate — the walk, when there is one,
|
||||||
|
since you are necessarily awake on a walk.
|
||||||
- Each tab is a `.tab-panel` wrapper around the existing sections. The
|
- Each tab is a `.tab-panel` wrapper around the existing sections. The
|
||||||
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
|
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
|
||||||
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
|
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
|
||||||
|
|||||||
+80
-15
@@ -985,6 +985,15 @@
|
|||||||
// counter without re-deriving from the event log.
|
// counter without re-deriving from the event log.
|
||||||
let bigClockState = null; // "asleep" | "awake" | null
|
let bigClockState = null; // "asleep" | "awake" | null
|
||||||
let bigClockSince = 0;
|
let bigClockSince = 0;
|
||||||
|
let walkSince = 0; // start of the walk in progress, 0 when none is
|
||||||
|
|
||||||
|
// The walk currently in progress, if any. pairWindows already marks an
|
||||||
|
// unmatched walk-start as ongoing, so this is just the last such window —
|
||||||
|
// there can only be one, since a start closes the previous pair.
|
||||||
|
function currentWalkStart(events) {
|
||||||
|
const open = walkWindows(events).find(w => w.ongoing);
|
||||||
|
return open ? open.start : 0;
|
||||||
|
}
|
||||||
|
|
||||||
// The counter renders twice: the big card at the top of the page and the
|
// The counter renders twice: the big card at the top of the page and the
|
||||||
// compact pill in the frozen day bar. The pill only *shows* once the big
|
// compact pill in the frozen day bar. The pill only *shows* once the big
|
||||||
@@ -992,17 +1001,21 @@
|
|||||||
// visible the pill is invisible but keeps its slot so the bar never shifts.
|
// visible the pill is invisible but keeps its slot so the bar never shifts.
|
||||||
function updateBarClockMode() {
|
function updateBarClockMode() {
|
||||||
const pill = document.getElementById("bar-clock");
|
const pill = document.getElementById("bar-clock");
|
||||||
if (!bigClockState || pill.hidden) return;
|
const walkPill = document.getElementById("bar-walk");
|
||||||
const card = document.getElementById("big-clock");
|
const card = document.getElementById("big-clock");
|
||||||
const bar = document.querySelector(".day-bar");
|
const bar = document.querySelector(".day-bar");
|
||||||
// The card lives on the Today tab, so on any other tab it isn't on screen
|
// 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
|
// at all and the pills are the only timers there are. Asked explicitly
|
||||||
// than left to the rect: a hidden element measures as zeroes, which would
|
// rather than left to the rect: a hidden element measures as zeroes, which
|
||||||
// give the right answer here by coincidence rather than by rule.
|
// would give the right answer here by coincidence rather than by rule.
|
||||||
const cardOnThisTab = card.offsetParent !== null;
|
const cardOnThisTab = card.offsetParent !== null;
|
||||||
const cardVisible = cardOnThisTab &&
|
const cardVisible = cardOnThisTab &&
|
||||||
card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom;
|
card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom;
|
||||||
|
// Both stand by together. standby keeps the slot rather than removing it,
|
||||||
|
// so the bar holds its height — including a wrapped second row — and the
|
||||||
|
// tab bar stuck to that height doesn't shift about as you scroll.
|
||||||
pill.classList.toggle("standby", cardVisible);
|
pill.classList.toggle("standby", cardVisible);
|
||||||
|
walkPill.classList.toggle("standby", cardVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBigClock(events) {
|
function renderBigClock(events) {
|
||||||
@@ -1016,36 +1029,81 @@
|
|||||||
const { state, since: ts } = currentSleepState(events);
|
const { state, since: ts } = currentSleepState(events);
|
||||||
bigClockState = state;
|
bigClockState = state;
|
||||||
bigClockSince = ts;
|
bigClockSince = ts;
|
||||||
|
|
||||||
|
// The walk pill is independent of the sleep one: it comes and goes with the
|
||||||
|
// walk, and the sleep pill is unaffected by either.
|
||||||
|
walkSince = currentWalkStart(events);
|
||||||
|
renderWalkPill();
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
card.hidden = true;
|
card.hidden = true;
|
||||||
pill.hidden = true;
|
pill.hidden = true;
|
||||||
|
// A walk can be running before any sleep has ever been logged, and the
|
||||||
|
// card is the only place its timer shows at the top of the day.
|
||||||
|
renderBigClockCard();
|
||||||
|
updateBarClockMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
card.hidden = false;
|
card.hidden = false;
|
||||||
pill.hidden = false;
|
pill.hidden = false;
|
||||||
card.classList.toggle("asleep", state === "asleep");
|
|
||||||
card.classList.toggle("awake", state === "awake");
|
|
||||||
pill.classList.toggle("asleep", state === "asleep");
|
pill.classList.toggle("asleep", state === "asleep");
|
||||||
pill.classList.toggle("awake", state === "awake");
|
pill.classList.toggle("awake", state === "awake");
|
||||||
label.textContent = state === "asleep" ? "Asleep for" : "Awake for";
|
|
||||||
const counter = formatCounter(Date.now() - ts);
|
const counter = formatCounter(Date.now() - ts);
|
||||||
time.textContent = counter;
|
|
||||||
ptime.textContent = counter;
|
ptime.textContent = counter;
|
||||||
since.textContent = `since ${formatTime(ts)}`;
|
|
||||||
icon.textContent = state === "asleep" ? "😴" : "☀️";
|
icon.textContent = state === "asleep" ? "😴" : "☀️";
|
||||||
const flip = state === "asleep" ? "Sleep end" : "Sleep start";
|
const flip = state === "asleep" ? "Sleep end" : "Sleep start";
|
||||||
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)} — tap to log ${flip.toLowerCase()}`;
|
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)} — tap to log ${flip.toLowerCase()}`;
|
||||||
pill.setAttribute("aria-label", `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}. Log ${flip.toLowerCase()}.`);
|
pill.setAttribute("aria-label", `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}. Log ${flip.toLowerCase()}.`);
|
||||||
|
renderBigClockCard();
|
||||||
updateBarClockMode();
|
updateBarClockMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
function tickBigClock() {
|
// The card shows one thing at a time, and a walk in progress is the more
|
||||||
if (!bigClockState) return;
|
// immediate of the two — you are necessarily awake on a walk, so "awake for
|
||||||
const counter = formatCounter(Date.now() - bigClockSince);
|
// 3h" is the less useful reading. The bar keeps both.
|
||||||
|
function renderBigClockCard() {
|
||||||
|
const card = document.getElementById("big-clock");
|
||||||
|
const label = document.getElementById("bc-label");
|
||||||
const time = document.getElementById("bc-time");
|
const time = document.getElementById("bc-time");
|
||||||
const ptime = document.getElementById("bar-clock-time");
|
const since = document.getElementById("bc-since");
|
||||||
if (time) time.textContent = counter;
|
const walking = walkSince > 0;
|
||||||
if (ptime) ptime.textContent = counter;
|
if (!walking && !bigClockState) { card.hidden = true; return; }
|
||||||
|
card.hidden = false;
|
||||||
|
card.classList.toggle("walking", walking);
|
||||||
|
card.classList.toggle("asleep", !walking && bigClockState === "asleep");
|
||||||
|
card.classList.toggle("awake", !walking && bigClockState === "awake");
|
||||||
|
const ts = walking ? walkSince : bigClockSince;
|
||||||
|
label.textContent = walking
|
||||||
|
? "Walking for"
|
||||||
|
: (bigClockState === "asleep" ? "Asleep for" : "Awake for");
|
||||||
|
time.textContent = formatCounter(Date.now() - ts);
|
||||||
|
since.textContent = `since ${formatTime(ts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWalkPill() {
|
||||||
|
const pill = document.getElementById("bar-walk");
|
||||||
|
const wtime = document.getElementById("bar-walk-time");
|
||||||
|
if (!walkSince) { pill.hidden = true; return; }
|
||||||
|
pill.hidden = false;
|
||||||
|
wtime.textContent = formatCounter(Date.now() - walkSince);
|
||||||
|
pill.title = `Walking since ${formatTime(walkSince)} — tap to log walk end`;
|
||||||
|
pill.setAttribute("aria-label", `Walking since ${formatTime(walkSince)}. Log walk end.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickBigClock() {
|
||||||
|
if (bigClockState) {
|
||||||
|
const ptime = document.getElementById("bar-clock-time");
|
||||||
|
if (ptime) ptime.textContent = formatCounter(Date.now() - bigClockSince);
|
||||||
|
}
|
||||||
|
if (walkSince) {
|
||||||
|
const wtime = document.getElementById("bar-walk-time");
|
||||||
|
if (wtime) wtime.textContent = formatCounter(Date.now() - walkSince);
|
||||||
|
}
|
||||||
|
// The card follows whichever of the two it is currently showing.
|
||||||
|
if (bigClockState || walkSince) {
|
||||||
|
const time = document.getElementById("bc-time");
|
||||||
|
if (time) time.textContent = formatCounter(Date.now() - (walkSince || bigClockSince));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) {
|
function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) {
|
||||||
@@ -4851,6 +4909,13 @@
|
|||||||
quickLog(state === "asleep" ? "sleep-end" : "sleep-start");
|
quickLog(state === "asleep" ? "sleep-end" : "sleep-start");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("bar-walk").addEventListener("click", () => {
|
||||||
|
// Re-read rather than trusting walkSince: the pill is only shown while a
|
||||||
|
// walk is open, but another device may have ended it since the last render.
|
||||||
|
if (!currentWalkStart(live())) return;
|
||||||
|
quickLog("walk-end");
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll(".chart-days-picker button").forEach(b => {
|
document.querySelectorAll(".chart-days-picker button").forEach(b => {
|
||||||
b.addEventListener("click", () => setChartDays(Number(b.dataset.days)));
|
b.addEventListener("click", () => setChartDays(Number(b.dataset.days)));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
|
{ "date": "2026-09-08", "text": "A walk in progress now has a timer in the frozen bar at the top, next to the asleep/awake one, counting from when the walk started — so you can see how long you have been out from any tab without going to look. Tapping it ends the walk, the same way tapping the sleep timer logs the sleep boundary. On a narrow phone two timers no longer fit beside the date controls, so the bar splits onto two rows while a walk is on and goes back to one when it ends. The big timer card on Today shows the walk too while there is one — you are awake on a walk either way, so the walk is the more useful of the two" },
|
||||||
{ "date": "2026-09-07", "text": "Going back to the Today tab no longer jumps the viewport either — by tapping it or with the back button. The other tabs stopped jumping in the last change, but returning to Today is a history step, and the browser was restoring the scroll position from when you last left it" },
|
{ "date": "2026-09-07", "text": "Going back to the Today tab no longer jumps the viewport either — by tapping it or with the back button. The other tabs stopped jumping in the last change, but returning to Today is a history step, and the browser was restoring the scroll position from when you last left it" },
|
||||||
{ "date": "2026-09-07", "text": "The tab row now matches the frozen row above it that holds the date. It was a different shade, and the two sat as separate bars with the date row's rounded corners cutting between them; they share the same card colour now, and once you have scrolled the log buttons away they join into a single rounded block instead of two stacked ones" },
|
{ "date": "2026-09-07", "text": "The tab row now matches the frozen row above it that holds the date. It was a different shade, and the two sat as separate bars with the date row's rounded corners cutting between them; they share the same card colour now, and once you have scrolled the log buttons away they join into a single rounded block instead of two stacked ones" },
|
||||||
{ "date": "2026-09-07", "text": "Switching tabs no longer jumps the page back to the top. The tab row is frozen to the top of the screen, so you change tabs from wherever you have scrolled to, and being thrown back past the log buttons you had just scrolled off was more disruptive than landing part-way down the new tab" },
|
{ "date": "2026-09-07", "text": "Switching tabs no longer jumps the page back to the top. The tab row is frozen to the top of the screen, so you change tabs from wherever you have scrolled to, and being thrown back past the log buttons you had just scrolled off was more disruptive than landing part-way down the new tab" },
|
||||||
|
|||||||
+32
-18
@@ -108,25 +108,39 @@
|
|||||||
<main>
|
<main>
|
||||||
|
|
||||||
<section class="day-bar">
|
<section class="day-bar">
|
||||||
<!-- Compact twin of the big timer below: invisible (but keeping its
|
<!-- Two groups, not seven loose children: on a narrow phone a running
|
||||||
slot) while the big card is on screen, shown once it scrolls
|
walk adds a second pill and the row no longer fits, so it wraps —
|
||||||
away. Hidden entirely until a sleep event exists. Tapping it logs
|
and it has to wrap between the timers and the day controls rather
|
||||||
the boundary that flips the current state (asleep → sleep end,
|
than splitting the controls across two lines. -->
|
||||||
awake → sleep start). -->
|
<span class="bar-timers">
|
||||||
<button type="button" id="bar-clock" class="bar-clock" hidden>
|
<!-- Compact twin of the big timer below: invisible (but keeping its
|
||||||
<span id="bar-clock-icon" aria-hidden="true"></span>
|
slot) while the big card is on screen, shown once it scrolls
|
||||||
<span id="bar-clock-time"></span>
|
away. Hidden entirely until a sleep event exists. Tapping it logs
|
||||||
</button>
|
the boundary that flips the current state (asleep → sleep end,
|
||||||
<button type="button" id="day-prev" class="ghost" aria-label="Previous day">←</button>
|
awake → sleep start). -->
|
||||||
<!-- A browser won't let us shorten the text a native date input shows,
|
<button type="button" id="bar-clock" class="bar-clock" hidden>
|
||||||
so the face button carries a compact year-less date and the real
|
<span id="bar-clock-icon" aria-hidden="true"></span>
|
||||||
input stays (visually hidden) as the value + native picker. -->
|
<span id="bar-clock-time"></span>
|
||||||
<span class="day-date">
|
</button>
|
||||||
<button type="button" id="day-date-face" class="ghost"></button>
|
<!-- Only while a walk is running. Tapping it ends the walk, the same
|
||||||
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
|
bargain the sleep pill offers. -->
|
||||||
|
<button type="button" id="bar-walk" class="bar-clock walking" hidden>
|
||||||
|
<span aria-hidden="true">🦮</span>
|
||||||
|
<span id="bar-walk-time"></span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span class="day-nav">
|
||||||
|
<button type="button" id="day-prev" class="ghost" aria-label="Previous day">←</button>
|
||||||
|
<!-- A browser won't let us shorten the text a native date input shows,
|
||||||
|
so the face button carries a compact year-less date and the real
|
||||||
|
input stays (visually hidden) as the value + native picker. -->
|
||||||
|
<span class="day-date">
|
||||||
|
<button type="button" id="day-date-face" class="ghost"></button>
|
||||||
|
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<button type="button" id="day-next" class="ghost" aria-label="Next day">→</button>
|
||||||
|
<button type="button" id="day-today" class="ghost">Today</button>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" id="day-next" class="ghost" aria-label="Next day">→</button>
|
|
||||||
<button type="button" id="day-today" class="ghost">Today</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="quick-actions">
|
<section class="quick-actions">
|
||||||
|
|||||||
+32
-3
@@ -270,7 +270,13 @@ body::before {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: nowrap;
|
/* Wraps only when it has to — which on a narrow phone is while a walk is
|
||||||
|
running and there are two timers to fit. The two groups above are what
|
||||||
|
make that wrap land in a sensible place. The bar's height changes when it
|
||||||
|
does, and the tab bar sticks to that height, so a ResizeObserver keeps
|
||||||
|
--day-bar-h honest (see measureDayBar). */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
/* The face button shows the short date; the real input sits invisibly behind
|
/* The face button shows the short date; the real input sits invisibly behind
|
||||||
@@ -309,15 +315,33 @@ body::before {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
/* The timers sit left, the day controls right. Both are groups so that when a
|
||||||
|
running walk makes the row too long the bar wraps between them, rather than
|
||||||
|
stranding "Today" on a line by itself. */
|
||||||
|
.bar-timers {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.day-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* An empty timer group must not hold a line open once it has wrapped. */
|
||||||
|
.bar-timers:empty { display: none; }
|
||||||
|
|
||||||
.bar-clock {
|
.bar-clock {
|
||||||
display: flex;
|
display: flex;
|
||||||
/* It is a button (tapping it flips the sleep state), so undo the default
|
/* It is a button (tapping it flips the sleep state), so undo the default
|
||||||
accent look — the asleep/awake classes below paint it. */
|
accent look — the asleep/awake/walking classes below paint it. */
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-right: auto; /* pin left; the flexible gap sits between it and the day controls */
|
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -333,6 +357,9 @@ body::before {
|
|||||||
12% where asleep takes 18% — equal percentages of these two hues are not
|
12% where asleep takes 18% — equal percentages of these two hues are not
|
||||||
equally strong, and yellow at 18% shouted while the blue did not. */
|
equally strong, and yellow at 18% shouted while the blue did not. */
|
||||||
.bar-clock.awake { background: color-mix(in srgb, var(--wake) 12%, var(--surface)); color: var(--wake-timer-ink); }
|
.bar-clock.awake { background: color-mix(in srgb, var(--wake) 12%, var(--surface)); color: var(--wake-timer-ink); }
|
||||||
|
/* The walk timer takes the walk colour the rest of the app already uses for
|
||||||
|
walks, so the pill says which of the two it is without needing its label. */
|
||||||
|
.bar-clock.walking { background: color-mix(in srgb, var(--walk) 16%, var(--surface)); color: var(--walk); }
|
||||||
|
|
||||||
.big-clock {
|
.big-clock {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -362,6 +389,8 @@ body::before {
|
|||||||
.big-clock.asleep .bc-time { color: var(--sleep-ink); }
|
.big-clock.asleep .bc-time { color: var(--sleep-ink); }
|
||||||
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--wake) 10%, var(--surface))); }
|
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--wake) 10%, var(--surface))); }
|
||||||
.big-clock.awake .bc-time { color: var(--wake-timer-ink); }
|
.big-clock.awake .bc-time { color: var(--wake-timer-ink); }
|
||||||
|
.big-clock.walking { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--walk) 10%, var(--surface))); }
|
||||||
|
.big-clock.walking .bc-time { color: var(--walk); }
|
||||||
|
|
||||||
/* Quick actions: a stack of explicit rows (see index.html) instead of one
|
/* 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
|
auto-fit grid, so the grouping is the same at every width. Equal columns
|
||||||
|
|||||||
Reference in New Issue
Block a user