Files
Paul LütznerandClaude Opus 5.5 8a7d56862c Read the time when the clock connects, so Back does not show an old one
The clock only wrote the time when the minute turned. A page the browser's
Back button restores carries the reading it was made with, and showed it
until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:57:06 +02:00

129 lines
5.2 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
/**
* Keep a server-rendered clock current.
*
* The server prints the first reading, in the user's zone and clock format, so
* the time is right on first paint and never blinks in. This only advances it:
* on the next minute boundary, then every minute after, and at once when the
* tab comes back from the background — a timer in a hidden tab is throttled,
* and a clock that is five minutes behind until the next tick is worse than
* none.
*
* It also reads the time once on connecting, because the markup it connects to
* is not always fresh from the server. The browser's Back button restores a
* page as it was when it was left — Turbo's snapshot, or the browser's own
* back/forward cache — and the reading in it is that old. Without this the
* clock went back with the page and stayed wrong until the minute turned. On a
* fresh page the reading it writes is the one already there.
*
* TARGETS, each optional, and each printed the way the server prints it
* ─────────────────────────────────────────────────────────────────────
* time "2:05 pm" / "14:05" — ClockFormat::time()
* bare "2:05" / "14:05" — the time without its meridiem
* meridiem "pm" / "" — for a layout that sets it smaller
* date any date text — rewritten only when the DAY changes
*
* The time is built from numbers rather than by an Intl pattern, because the
* server's is PHP's `g:i a` and no locale's Intl output is guaranteed to match
* it character for character; a clock whose "pm" turns into "PM" on the first
* tick looks broken. Dates are the reverse: they are words, in the page's
* language, so they come from Intl — and are therefore written only when the
* day actually changes, so a small difference from the server's ICU never
* shows as a flicker on the first tick.
*
* Each date target carries its own Intl options as JSON in `data-clock-date`,
* so one controller serves "Wednesday 23 September" and "Wed" alike.
*/
export default class extends Controller {
static targets = ["time", "bare", "meridiem", "date"];
static values = { zone: String, hour12: Boolean, day: String };
connect() {
this.onVisible = () => {
if ("visible" === document.visibilityState) {
this.tick();
}
};
// The back/forward cache brings the page back with this controller
// still connected, so connect() does not run again for it.
this.onPageShow = (event) => {
if (event.persisted) {
this.tick();
this.schedule();
}
};
document.addEventListener("visibilitychange", this.onVisible);
window.addEventListener("pageshow", this.onPageShow);
this.tick();
this.schedule();
}
disconnect() {
clearTimeout(this.timer);
document.removeEventListener("visibilitychange", this.onVisible);
window.removeEventListener("pageshow", this.onPageShow);
}
schedule() {
clearTimeout(this.timer);
const now = Date.now();
// A beat past the boundary, so the reading is never the minute before.
this.timer = setTimeout(() => {
this.tick();
this.schedule();
}, 60000 - (now % 60000) + 50);
}
tick() {
const now = new Date();
const parts = this.parts(now);
const hour = Number(parts.hour) % 24;
const minute = parts.minute.padStart(2, "0");
let bare;
let meridiem = "";
if (this.hour12Value) {
bare = `${hour % 12 || 12}:${minute}`;
meridiem = hour < 12 ? "am" : "pm";
} else {
bare = `${String(hour).padStart(2, "0")}:${minute}`;
}
this.timeTargets.forEach((el) => (el.textContent = meridiem ? `${bare} ${meridiem}` : bare));
this.bareTargets.forEach((el) => (el.textContent = bare));
this.meridiemTargets.forEach((el) => (el.textContent = meridiem));
const day = `${parts.year}-${parts.month}-${parts.day}`;
if (day !== this.dayValue) {
this.dayValue = day;
const lang = document.documentElement.lang || undefined;
this.dateTargets.forEach((el) => {
const options = JSON.parse(el.dataset.clockDate || "{}");
try {
el.textContent = now.toLocaleDateString(lang, { ...options, timeZone: this.zone() });
} catch {
el.textContent = now.toLocaleDateString(undefined, { ...options, timeZone: this.zone() });
}
});
}
}
parts(now) {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: this.zone(),
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
return Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value]));
}
zone() {
return this.zoneValue || undefined;
}
}