diff --git a/docs/release.md b/docs/release.md index 923cc98..451343b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,7 +6,9 @@ machine expects to find applications: `/Applications` on macOS, or the package manager on Linux. It is the same command whether or not the app is already installed, so it doubles as the update. On macOS it asks a running copy to quit first, because replacing a bundle under a live process -leaves it half old and half new. `just uninstall` reverses it and leaves the data directory alone. +leaves it half old and half new, and starts the new one once it is in place, so the copy on screen +is never older than the copy installed. `just uninstall` reverses it and leaves the data directory +alone. The local build skips the dmg and builds only the `.app`, since nothing about copying a bundle into place needs a disk image and building one is the slowest part of a mac bundle. That makes a diff --git a/justfile b/justfile index 526e0c0..ccb5c8e 100644 --- a/justfile +++ b/justfile @@ -37,7 +37,7 @@ build: *) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;; esac -# Build and install, replacing whatever version is already installed. +# Build, install over whatever version is already installed, and start the new one. install: build #!/usr/bin/env bash set -euo pipefail @@ -75,6 +75,7 @@ _install-macos: cp -R "$src" "$dest" version=$(defaults read "$dest/Contents/Info.plist" CFBundleShortVersionString 2> /dev/null || echo "?") echo "Installed $version to $dest" + open "$dest" _install-linux: #!/usr/bin/env bash diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f358c5..44668e8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2276,7 +2276,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "margin-calendar" -version = "0.0.4" +version = "0.0.5" dependencies = [ "base64 0.22.1", "block2", diff --git a/src/useClock.ts b/src/useClock.ts index b9cd80c..92742d8 100644 --- a/src/useClock.ts +++ b/src/useClock.ts @@ -1,15 +1,15 @@ // The wall clock, at the two granularities anything on the grid cares about: the minute the now // line moves on, and the hour the fit holds open around it. // -// Both schedule off the clock rather than off an interval, so a tab that was asleep and a machine -// that woke up catch up on the next tick instead of drifting further out every hour. A timer is -// still a timer, though: one set before the lid closed can fire long after its wall-clock -// deadline, and a hidden window's timers run late or not at all. So the window coming back, as a -// focus or a visibility change, is read as a tick of its own. +// Both are read off the clock once a minute rather than counted down to. A timer's deadline is +// measured in time the machine spent awake, so one set for the top of the hour before the lid +// closed is still waiting out the rest of that hour when it opens, however late it is by then. +// Reading once a minute puts a minute on how far behind either can be; the window coming back, as +// a focus or a visibility change, is read as a tick of its own so it is usually less. // // They are separate hooks because they are separate rerenders. The minute belongs to the one -// component that draws the line; putting the fit on that tick would solve the whole axis sixty -// times an hour to answer a question that changes once. +// component that draws the line. The hour is read as often but only changes once an hour, and a +// state set to the value it already holds rerenders nothing. import { useEffect, useState } from "react"; @@ -22,20 +22,12 @@ function hourStart(ms: number): number { return d.getTime(); } -/** The next one. `setMinutes(60)` rolls the date over for us, daylight saving and all. */ -function nextHourStart(ms: number): number { - const d = new Date(ms); - d.setMinutes(60, 0, 0); - return d.getTime(); -} - -function useTick(read: (now: number) => T, next: (now: number) => number): T { +function useTick(read: (now: number) => T): T { const [value, setValue] = useState(() => read(Date.now())); useEffect(() => { let timer = 0; const schedule = () => { - const now = Date.now(); - timer = window.setTimeout(tick, Math.max(0, next(now) - now)); + timer = window.setTimeout(tick, MINUTE - (Date.now() % MINUTE)); }; const tick = () => { window.clearTimeout(timer); @@ -53,25 +45,24 @@ function useTick(read: (now: number) => T, next: (now: number) => number): T document.removeEventListener("visibilitychange", woke); window.removeEventListener("focus", woke); }; - // Both callbacks are module-level functions in every caller, so there is nothing to rebind. - }, [read, next]); + // `read` is a module-level function in every caller, so there is nothing to rebind. + }, [read]); return value; } const now = (ms: number) => ms; -const nextMinute = (ms: number) => ms + MINUTE - (ms % MINUTE); /** Now, refreshed on every wall-clock minute. */ export function useMinuteTick(): number { - return useTick(now, nextMinute); + return useTick(now); } /** - * The top of the hour it is now, refreshed when the clock turns over into the next one. + * The top of the hour it is now, read every minute and changed once an hour. * * Returning the hour rather than the instant is what makes this cheap to depend on: the value is * identical for a whole hour, so a memo keyed on it recomputes once, when the answer changed. */ export function useHourStart(): number { - return useTick(hourStart, nextHourStart); + return useTick(hourStart); } diff --git a/tests/grid.spec.ts b/tests/grid.spec.ts index 01ba166..bf2d1d8 100644 --- a/tests/grid.spec.ts +++ b/tests/grid.spec.ts @@ -280,6 +280,52 @@ test.describe("the hour it is now", () => { expect((await axis(page)).map((entry) => entry.text)).not.toContain("11pm"); await expect(page.locator(".grid-now")).toHaveCount(0); }); + + // The two below are about an app left open, so the clock is a running one rather than a pinned + // instant: `install` lets it be moved, and the strip that reads "8pm to 10pm" only exists once + // the hour it holds open has moved from nine to ten. + test("moves on with the clock while the app stays open", async ({ page }) => { + await page.clock.install({ time: clockAt(21, 58) }); + await openApp(page); + await page.clock.pauseAt(clockAt(21, 59)); + const before = (await axis(page)).map((entry) => entry.text); + expect(before).toContain("9pm"); + expect(before).not.toContain("10pm"); + + await page.clock.runFor(2 * 60_000); + + await expect(page.locator(".grid-strip", { hasText: "8pm to 10pm" })).toHaveCount(1); + const after = (await axis(page)).map((entry) => entry.text); + expect(after).toContain("10pm"); + expect(after).not.toContain("9pm"); + await expect(page.locator(".grid-now")).toHaveCount(1); + }); + + test("catches up when the clock jumped, as it does after the lid was closed", async ({ page }) => { + await page.clock.install({ time: clockAt(21, 30) }); + await openApp(page); + await page.clock.pauseAt(clockAt(21, 31)); + expect((await axis(page)).map((entry) => entry.text)).toContain("9pm"); + + // Asleep, the wall clock moves on and the timers do not: one set for the top of the hour is + // still waiting for the rest of its half hour when the machine wakes at five past ten. What + // the app gets is one minute of being awake. + await page.clock.setSystemTime(clockAt(22, 5)); + await page.clock.runFor(60_000); + + await expect(page.locator(".grid-strip", { hasText: "8pm to 10pm" })).toHaveCount(1); + const hours = (await axis(page)).map((entry) => entry.text); + expect(hours).toContain("10pm"); + expect(hours).not.toContain("9pm"); + await expect(page.locator(".grid-now")).toHaveCount(1); + + const line = await box(page.locator(".grid-now")); + const row = (await gridFit(page)).rowHeight; + const ten = (await axis(page)).find((entry) => entry.text === "10pm"); + const canvas = await box(page.locator(".grid-canvas")); + expect(line.top - canvas.top).toBeGreaterThan(ten!.y); + expect(line.top - canvas.top).toBeLessThan(ten!.y + row); + }); }); test.describe("the all-day band", () => {