diff --git a/docs/design.md b/docs/design.md index c9c8877..71f144b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -30,6 +30,14 @@ The range expands immediately when it needs to, but only contracts when it would hours or more. Without that hysteresis the axis flickers as you page through weeks, and a flickering axis destroys the positional memory that makes a keyboard-driven calendar fast. +The hour you are in is the one exception to the range being the events' business. At half +eleven at night the events stopped hours ago, so the now line has nowhere to land and the app +stops telling you where in the day you are, which is most of what a calendar is open for. So +when today is on screen the axis takes that hour in, and folds away everything it reached over +to get there: the axis grows by a row and a strip, not by an evening. Page to a week that does +not contain today and it goes again, which is the one place the axis is allowed to move under +you. + Interior gaps stay at full scale. A three-hour hole on a Wednesday afternoon is the most useful thing on the screen, because it is where work goes, and folding it automatically would make a packed day look identical to an open one. But you can fold one deliberately with `z`, diff --git a/src/components/GridNowLine.tsx b/src/components/GridNowLine.tsx index 2c43aef..ba8b4be 100644 --- a/src/components/GridNowLine.tsx +++ b/src/components/GridNowLine.tsx @@ -1,30 +1,12 @@ // The now line, and the wash over the part of today that has already gone. A minute is as fine as -// either needs to be, and the tick lives here so the rest of the grid never rerenders for it. +// either needs to be, and this is the only thing on the tick, so the rest of the grid never +// rerenders for it. -import { useEffect, useState } from "react"; import { isFolded, timeToY, type FitLayout } from "../grid/fit"; import { isSameDay, minutesFromMidnight } from "../time"; +import { useMinuteTick } from "../useClock"; import { vars } from "./GridModel"; -const MINUTE = 60_000; - -/** Rounds up to the next wall-clock minute, so the line moves when the clock does. */ -function useMinuteTick(): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - let timer = 0; - const schedule = () => { - timer = window.setTimeout(() => { - setNow(Date.now()); - schedule(); - }, MINUTE - (Date.now() % MINUTE)); - }; - schedule(); - return () => window.clearTimeout(timer); - }, []); - return now; -} - interface GridNowLineProps { layout: FitLayout; dayStart: number; diff --git a/src/components/GridView.tsx b/src/components/GridView.tsx index 123fe1c..0f7f4d9 100644 --- a/src/components/GridView.tsx +++ b/src/components/GridView.tsx @@ -40,6 +40,7 @@ import { toOffsetIso, today, } from "../time"; +import { useHourStart } from "../useClock"; import { PHONE_QUERY, useMediaQuery } from "../useMedia"; import "../styles/grid.css"; import "../styles/folds.css"; @@ -237,6 +238,22 @@ export function GridView({ defaultCalendarId }: GridViewProps) { const timed = useMemo(() => byDay.flat(), [byDay]); + // The hour the clock is in, when today is one of the columns. + // + // The axis is drawn from the events, and at nine in the evening the events are usually behind + // you: the bounds end at six, the rest of the day is in the trailing strip, and the now line has + // nowhere to land. So the hour you are in is taken into the bounds when it falls outside them. + // One hour, and only the hour, so the axis gains a row rather than an evening. + // + // Keyed on the hour rather than the minute, because that is how often the answer changes. The + // line itself moves every minute, on its own tick, in `GridNowLine`. + const hourStart = useHourStart(); + const nowBand = useMemo((): Bounds | null => { + if (!days.includes(startOfDay(hourStart))) return null; + const hour = new Date(hourStart).getHours(); + return { start: hour, end: hour + 1 }; + }, [days, hourStart]); + // Hysteresis needs the bounds it adopted last pass, so they live in a ref rather than in state: // paging a week must not reflow the axis, and a render loop through state would fight that. const layout = useMemo(() => { @@ -244,9 +261,23 @@ export function GridView({ defaultCalendarId }: GridViewProps) { const bounds = floor ? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) } : auto; + // What the events and the user asked for is what the hysteresis remembers. The clock's hour is + // a pin laid over the top of it every hour, so it never accumulates into the axis it pins. previous.current = bounds; - return computeFit({ bounds, folds, viewportHeight: viewportH }); - }, [timed, folds, floor, viewportH]); + if (!nowBand) return computeFit({ bounds, folds, viewportHeight: viewportH }); + + // Everything the widening reached over folds behind it. At half eleven at night the axis is + // one row longer and one strip taller, rather than four rows of empty evening shorter. + const reach: Fold[] = [ + { start: nowBand.end, end: bounds.start }, + { start: bounds.end, end: nowBand.start }, + ]; + const axis = { + start: Math.min(bounds.start, nowBand.start), + end: Math.max(bounds.end, nowBand.end), + }; + return computeFit({ bounds: axis, folds: [...folds, ...reach], viewportHeight: viewportH }); + }, [timed, folds, floor, viewportH, nowBand]); useEffect(() => { const signature = `${layout.bounds.start} ${layout.bounds.end}`; diff --git a/src/useClock.ts b/src/useClock.ts new file mode 100644 index 0000000..efc7bf1 --- /dev/null +++ b/src/useClock.ts @@ -0,0 +1,66 @@ +// 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. +// +// 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. + +import { useEffect, useState } from "react"; + +const MINUTE = 60_000; + +/** The top of the local hour `ms` falls in. Not `ms - ms % HOUR`: not every zone is on the hour. */ +function hourStart(ms: number): number { + const d = new Date(ms); + d.setMinutes(0, 0, 0); + 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 { + const [value, setValue] = useState(() => read(Date.now())); + useEffect(() => { + let timer = 0; + const schedule = () => { + const now = Date.now(); + timer = window.setTimeout( + () => { + setValue(read(Date.now())); + schedule(); + }, + Math.max(0, next(now) - now), + ); + }; + schedule(); + return () => window.clearTimeout(timer); + // Both callbacks are module-level functions in every caller, so there is nothing to rebind. + }, [read, next]); + 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); +} + +/** + * The top of the hour it is now, refreshed when the clock turns over into the next one. + * + * 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); +} diff --git a/tests/app.ts b/tests/app.ts index a47d7f0..4f16444 100644 --- a/tests/app.ts +++ b/tests/app.ts @@ -23,8 +23,35 @@ export interface OpenOptions { view?: "day" | "week" | "agenda"; /** Extra localStorage entries, written before the app's first script runs. */ storage?: Record; + /** Pins the browser's clock. See `clockAt`. */ + now?: Date; } +/** + * A fixed instant on the day the suite is being run, at a given hour of the pinned zone. + * + * The axis takes in the hour it is now, so the shape of the grid depends on when you run the + * suite: at eleven at night it carries a row and a strip that it does not carry at eleven in the + * morning. A test that measures the axis has to say which of those it means, the same way it says + * which theme and which week start it means. The one test that is *about* the clock asks for an + * hour the fixture leaves empty; every other one asks for the middle of the working day. + * + * Asia/Kolkata is UTC+5:30 the whole year round, so the offset can be written out. + */ +export function clockAt(hour: number, minute = 0): Date { + const day = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Kolkata", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); + const pad = (n: number) => String(n).padStart(2, "0"); + return new Date(`${day}T${pad(hour)}:${pad(minute)}:00+05:30`); +} + +/** The middle of the working day: the fixture is busy, and the clock pins nothing extra open. */ +export const MIDDAY = () => clockAt(11, 30); + const SEEDED = "__test-seeded"; /** @@ -56,6 +83,7 @@ export async function openApp(page: Page, options: OpenOptions = {}): Promise { }); test("no block is painted over by the one after it", async ({ page }) => { - await openApp(page); + // The row height is the subject here, so the axis has to be the one the events drew: an hour + // pinned open for the clock is fewer pixels for every block on it. + await openApp(page, { now: MIDDAY() }); // A block shorter than the minimum block height is drawn taller than its own time, which is // right: it has to stay hittable. It must not be drawn *under* the next block, though, or the diff --git a/tests/grid.spec.ts b/tests/grid.spec.ts index c70c6b2..e531fa3 100644 --- a/tests/grid.spec.ts +++ b/tests/grid.spec.ts @@ -5,7 +5,18 @@ // are measured rather than asserted about the DOM. import { expect, test } from "@playwright/test"; -import { axis, blocks, box, gridFit, gridReady, headerDates, openApp, settle } from "./app"; +import { + MIDDAY, + axis, + blocks, + box, + clockAt, + gridFit, + gridReady, + headerDates, + openApp, + settle, +} from "./app"; test.describe("the grid fits the window", () => { test("the body is exactly the height the window left it, with nothing to scroll", async ({ @@ -71,9 +82,13 @@ test.describe("the grid fits the window", () => { }); }); +// Every test here pins the clock to the middle of the working day. The axis takes in the hour it +// is now when the events have left it out, so the shape of a quiet evening is not the shape of a +// busy morning, and an axis test that did not say which one it meant would pass or fail on when it +// was run. What the clock does to the axis is the last test in the file. test.describe("the axis holds still", () => { test("paging a week keeps the same hours on screen", async ({ page }) => { - await openApp(page); + await openApp(page, { now: MIDDAY() }); const before = (await axis(page)).map((entry) => entry.text); const dates = await headerDates(page); expect(before.length).toBeGreaterThan(6); @@ -95,7 +110,7 @@ test.describe("the axis holds still", () => { // axis is solved from what the band left over, so paging past them rescales every hour on the // grid. The comment at the top of GridAllDay.tsx says the band never does this. test("paging a week does not move the hours on screen either", async ({ page }) => { - await openApp(page); + await openApp(page, { now: MIDDAY() }); const before = await axis(page); const band = (await box(page.locator(".grid-allday"))).height; const row = (await gridFit(page)).rowHeight; @@ -113,7 +128,7 @@ test.describe("the axis holds still", () => { }); test("paging back does not reflow them either", async ({ page }) => { - await openApp(page); + await openApp(page, { now: MIDDAY() }); const before = (await axis(page)).map((entry) => entry.text); for (let i = 0; i < 4; i++) { await page.keyboard.press("h"); @@ -123,7 +138,7 @@ test.describe("the axis holds still", () => { }); test("the empty ends of the day are folded into a strip at each end", async ({ page }) => { - await openApp(page); + await openApp(page, { now: MIDDAY() }); const strips = page.locator(".grid-strip"); await expect(strips).toHaveCount(2); // Midnight to the first event, and the last event to midnight: both say what they cover. @@ -132,6 +147,54 @@ test.describe("the axis holds still", () => { }); }); +test.describe("the hour it is now", () => { + // Half eleven at night, which the fixture leaves empty: the axis the events drew stops hours + // earlier, so without the pin there is nowhere for the line to be. + const LATE = () => clockAt(23, 30); + + test("is on the axis even when the events stopped hours ago", async ({ page }) => { + await openApp(page, { now: LATE() }); + + expect((await axis(page)).map((entry) => entry.text)).toContain("11pm"); + await expect(page.locator(".grid-now")).toHaveCount(1); + + // On today's column, and inside the row it belongs to rather than on the strip above it. + const line = await box(page.locator(".grid-now")); + const row = (await gridFit(page)).rowHeight; + const eleven = (await axis(page)).find((entry) => entry.text === "11pm"); + const canvas = await box(page.locator(".grid-canvas")); + expect(line.top - canvas.top).toBeGreaterThan(eleven!.y); + expect(line.top - canvas.top).toBeLessThan(eleven!.y + row); + }); + + test("costs one row, not the whole evening it reached over", async ({ page }) => { + await openApp(page, { now: LATE() }); + const hours = (await axis(page)).map((entry) => entry.text); + + // The hours between the last event and now are not on the axis; they are in a strip. + expect(hours).not.toContain("9pm"); + expect(hours).not.toContain("10pm"); + await expect(page.locator(".grid-strip", { hasText: /to 11pm/ })).toHaveCount(1); + // And the day still fits. + const fit = await gridFit(page); + expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0); + expect(fit.overflow).toBe(false); + }); + + test("is gone again on a week that does not contain today", async ({ page }) => { + await openApp(page, { now: LATE() }); + const before = (await axis(page)).map((entry) => entry.text); + expect(before).toContain("11pm"); + + // A whole week on, today is not one of the columns and neither is the hour it is now. + await page.keyboard.press("L"); + await settle(page); + + expect((await axis(page)).map((entry) => entry.text)).not.toContain("11pm"); + await expect(page.locator(".grid-now")).toHaveCount(0); + }); +}); + test.describe("the all-day band", () => { test("a multi-day event spans exactly the days it covers", async ({ page }) => { await openApp(page); diff --git a/tests/keyboard.spec.ts b/tests/keyboard.spec.ts index 70f94f5..5a22183 100644 --- a/tests/keyboard.spec.ts +++ b/tests/keyboard.spec.ts @@ -4,7 +4,17 @@ // asking a store what it thinks happened. import { expect, test } from "@playwright/test"; -import { axis, columnX, gridFit, gridReady, headerDates, hourY, openApp, settle } from "./app"; +import { + MIDDAY, + axis, + columnX, + gridFit, + gridReady, + headerDates, + hourY, + openApp, + settle, +} from "./app"; const view = (page: import("@playwright/test").Page) => page.evaluate(() => document.documentElement.getAttribute("data-view")); @@ -72,7 +82,8 @@ test.describe("views", () => { test("z folds the band under the cursor, and it is still folded after a reload", async ({ page, }) => { - await openApp(page); + // Counted strips, so the clock must not be adding one of its own. + await openApp(page, { now: MIDDAY() }); const fit = await gridFit(page); const evening = await hourY(page, "5pm"); expect(evening).not.toBeNull();