Drop the big clock; keep both timers in the bar, with seconds

The asleep/awake counter rendered twice: a big card at the top of Today, and a
pill in the frozen bar that stayed invisible until the card scrolled out of
sight. That arrangement made the timer hardest to see exactly when you wanted
it — it lived on one tab in five, and hid itself whenever it was on screen.

So the card goes. Both timers sit in the frozen bar, on every tab, always
visible. That takes the standby mechanism with it, along with the rect
comparison that decided when to engage it, renderBigClockCard, and one of the
two jobs the scroll handler was doing.

Having a single place to render them is also what pays for the seconds. They
were cut to minutes last change to buy width; the month grid has since given
back about 93px by taking Today off the bar, which more than covers the 32px
the seconds cost. One row from 360px up now, wrapping only on a 320px SE and
the 280px foldable.

The pills stay at 0.9rem rather than going back to 1rem. It is tempting now
they are the only timer, but there are 6px of slack at 375px and the larger
type adds about 10, which would wrap an iPhone SE 2 and an iPhone 8.
This commit is contained in:
Alexander Heldt
2026-09-09 04:13:03 +00:00
parent fbacd97da7
commit e4b056b5b9
5 changed files with 44 additions and 158 deletions
+29 -108
View File
@@ -629,18 +629,6 @@
return { walking: latest.type === "walk-start", since: latest.at };
}
// Minutes rather than seconds, for the frozen bar only. Two timers and the
// day controls only fit on one row if the timers are short, and a counter
// ticking every second at pill size is motion rather than information — the
// big card keeps its seconds, which is where you look if you want them.
function formatPillDuration(ms) {
if (ms < 0) ms = 0;
const totalMin = Math.floor(ms / 60000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return h > 0 ? `${h}h${String(m).padStart(2, "0")}` : `${m}m`;
}
function formatCounter(ms) {
if (ms < 0) ms = 0;
const totalSec = Math.floor(ms / 1000);
@@ -995,8 +983,8 @@
// Tracks the latest sleep transition so the 1-second tick can update the
// counter without re-deriving from the event log.
let bigClockState = null; // "asleep" | "awake" | null
let bigClockSince = 0;
let sleepState = null; // "asleep" | "awake" | null
let sleepSince = 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
@@ -1007,88 +995,34 @@
return open ? open.start : 0;
}
// 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
// card is scrolled out of sight (see updateBarClockMode); while the card is
// visible the pill is invisible but keeps its slot so the bar never shifts.
function updateBarClockMode() {
const pill = document.getElementById("bar-clock");
const walkPill = document.getElementById("bar-walk");
const card = document.getElementById("big-clock");
const bar = document.querySelector(".day-bar");
// The card lives on the Today tab, so on any other tab it isn't on screen
// at all and the pills are the only timers there are. 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;
// 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);
walkPill.classList.toggle("standby", cardVisible);
}
function renderBigClock(events) {
const card = document.getElementById("big-clock");
const label = document.getElementById("bc-label");
const time = document.getElementById("bc-time");
const since = document.getElementById("bc-since");
// Both timers live in the frozen bar and nowhere else. There used to be a big
// card at the top of Today as well, with the pills standing by until it
// scrolled out of sight — but a timer you have to scroll to, on one tab out
// of five, is not doing the job a timer is for. The bar shows them always,
// and having only one place to render them is what lets them carry seconds
// again: they are the display now, not a summary of one.
function renderTimers(events) {
const pill = document.getElementById("bar-clock");
const icon = document.getElementById("bar-clock-icon");
const ptime = document.getElementById("bar-clock-time");
const { state, since: ts } = currentSleepState(events);
bigClockState = state;
bigClockSince = ts;
sleepState = state;
sleepSince = 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.
// The two are independent: the walk pill comes and goes with the walk, and
// the sleep pill is unaffected by it.
walkSince = currentWalkStart(events);
renderWalkPill();
if (!state) {
card.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;
}
card.hidden = false;
if (!state) { pill.hidden = true; return; }
pill.hidden = false;
pill.classList.toggle("asleep", state === "asleep");
pill.classList.toggle("awake", state === "awake");
ptime.textContent = formatPillDuration(Date.now() - ts);
ptime.textContent = formatCounter(Date.now() - ts);
icon.textContent = state === "asleep" ? "😴" : "☀️";
const flip = state === "asleep" ? "Sleep end" : "Sleep start";
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()}.`);
renderBigClockCard();
updateBarClockMode();
}
// The card shows one thing at a time, and a walk in progress is the more
// immediate of the two — you are necessarily awake on a walk, so "awake for
// 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 since = document.getElementById("bc-since");
const walking = walkSince > 0;
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() {
@@ -1096,24 +1030,19 @@
const wtime = document.getElementById("bar-walk-time");
if (!walkSince) { pill.hidden = true; return; }
pill.hidden = false;
wtime.textContent = formatPillDuration(Date.now() - walkSince);
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) {
function tickTimers() {
if (sleepState) {
const ptime = document.getElementById("bar-clock-time");
if (ptime) ptime.textContent = formatPillDuration(Date.now() - bigClockSince);
if (ptime) ptime.textContent = formatCounter(Date.now() - sleepSince);
}
if (walkSince) {
const wtime = document.getElementById("bar-walk-time");
if (wtime) wtime.textContent = formatPillDuration(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));
if (wtime) wtime.textContent = formatCounter(Date.now() - walkSince);
}
}
@@ -2852,7 +2781,7 @@
renderChartWindow();
// Day-scoped panels get the full list: you navigated to this day, so you
// should see what is actually on it, marked or not.
renderBigClock(events);
renderTimers(events);
renderActionHints(events);
renderStats(events);
renderLasts(events);
@@ -4844,10 +4773,8 @@
history.back();
}
}
// The pill's visibility depends on whether the big card is on screen, and
// the card only exists on one tab. A shorter tab can also leave the page
// too short to stay scrolled, unsticking the bars, so re-check that too.
updateBarClockMode();
// A shorter tab can leave the page too short to stay scrolled, which
// unsticks the two frozen bars and parts them again.
updateTabsMerged();
}
@@ -4912,8 +4839,7 @@
// The timer pill doubles as a one-tap sleep toggle: tapping it logs the
// boundary that ends the state it shows, exactly like the matching quick
// action. Only reachable once the big card has scrolled away — in standby
// the pill is visibility:hidden, so clicks can never land on it.
// action.
document.getElementById("bar-clock").addEventListener("click", () => {
const { state } = currentSleepState(live());
if (!state) return; // no sleep history yet; the pill is hidden anyway
@@ -4939,19 +4865,14 @@
});
});
// Swap the timer pill in/out of the frozen bar as the big card scrolls past,
// and join the two frozen bars up once they meet. rAF-throttled: scroll
// events fire far more often than we can paint.
// Join the two frozen bars up once they meet. rAF-throttled: scroll events
// fire far more often than we can paint.
{
let queued = false;
const onScroll = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
updateBarClockMode();
updateTabsMerged();
});
requestAnimationFrame(() => { queued = false; updateTabsMerged(); });
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
@@ -5266,7 +5187,7 @@
setInterval(() => {
const evs = live();
renderHeader();
renderBigClock(evs);
renderTimers(evs);
renderActionHints(evs);
renderStats(evs);
renderLasts(evs);
@@ -5282,7 +5203,7 @@
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
setInterval(tickBigClock, 1000);
setInterval(tickTimers, 1000);
setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);