diff --git a/AGENTS.md b/AGENTS.md index a8e7b4c..da35fdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,9 +112,10 @@ src/client/ React app, service worker, manifest state/ progress, daily log, ignores, prefs, sort — all localStorage useCustom.ts — the reader's own games and events (PRD F13) lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure + zoom.ts — the timeline's scale ladder; pure scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) serve.ts static server + /api/health -test/ 560 tests +test/ 579 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c27ef93..4cf40b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -125,6 +125,7 @@ src/ gameMeta.tsx lane id → name, label, hue; resolves custom lanes too sort.ts deadline order, or what you're partway through lens.ts who sees which rows — focus, outstanding, next-to-expire + zoom.ts the timeline's scale ladder; pure useAppUpdate.ts is a newer build waiting, and taking it (F14) serve.ts static server + /api/health ✓ built scripts/ diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 6db589c..ee52bac 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -201,8 +201,12 @@ Namespaced, versioned, and small. Nothing here ever goes to the server. "gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } } "gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this" "gacha-tracker:v1:prefs" // { region, hiddenGames[], knownGames[]?, focusGame, sort, view, - // detectDaily, showCompleted, showIgnored, regionConfirmed, - // onboarded } + // timelineDayWidth, detectDaily, showCompleted, showIgnored, + // regionConfirmed, onboarded } + // timelineDayWidth is px per day on the board, stored as the + // measurement rather than a step number and read through + // snapDayWidth — so a value from an older ladder still opens + // on something renderable. // knownGames is every lane the reader has been offered. Absent // means unrecorded, not "offered nothing" — see PRD F8; a lane // missing from it is new to them and arrives switched off. diff --git a/docs/PRD.md b/docs/PRD.md index bb8dbef..02764bc 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -80,6 +80,16 @@ calendar on screen, and nothing left saying which day, whose game, or which even six-week bar starts weeks off-screen, so a name that rides off with its own start date leaves a coloured rectangle behind. +**The reader sets the scale.** A patch cycle is six weeks and a login campaign can run for months, +so no single density answers both "what am I in the middle of this week?" and "how do the next three +months line up?". A pair of controls steps through a ladder of day widths, and the choice is +remembered (`prefs.timelineDayWidth`) — the same argument as the view tabs: a reader who has said how +they want to read this should not have to say it again on the next load. Two things it has to get +right. Zooming holds the middle of the view still, because rescaling around the left edge of a +three-month board throws away whatever the reader had scrolled to. And the dated ticks thin out as +the scale shrinks, since a week is 42px at the widest setting and the dates would sit on top of one +another; the gridlines stay weekly either way, because they carry the rhythm rather than the reading. + **The board draws at most two months of past.** A standing login campaign can have been running for half a year, and drawing from the earliest start bought months of empty calendar that nobody scrolls back through and that pushed every other bar off to the right. An event older than the board keeps diff --git a/src/client/App.tsx b/src/client/App.tsx index 3aedb86..75dde8f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -18,6 +18,7 @@ import { useMarkSet } from "./state/useMarkSet.ts"; import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { adoptNewLanes, usePrefs, type View } from "./state/usePrefs.ts"; +import { snapDayWidth } from "./state/zoom.ts"; import { useCustom } from "./state/useCustom.ts"; import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; import { @@ -509,7 +510,17 @@ export function App() { ) : ( - + update({ timelineDayWidth })} + onOpen={setOpenId} + isDone={isDone} + /> )} void; onOpen: (id: string) => void; /** * Asked rather than derived from the progress store: an entry exists there @@ -82,9 +86,9 @@ export function Timeline({ const starts = rows.map((r) => r.clock.startsMs); const { min, max } = boardWindow(starts, ends, now); const totalDays = Math.ceil((max - min) / DAY); - const chartWidth = totalDays * DAY_WIDTH; + const chartWidth = totalDays * dayWidth; /** One coordinate space for everything: bars, gridlines and the now rule. */ - const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH; + const x = (ms: number) => ((ms - min) / DAY) * dayWidth; // Open at today rather than at the far past, with a little of the past week // still on screen — an event that began three days ago is context, not @@ -94,8 +98,39 @@ export function Timeline({ const jumpToNow = (behavior: ScrollBehavior) => scroller.current?.scrollTo({ left: openAt, behavior }); - useEffect(() => { - jumpToNow("instant"); + /** + * A moment in time to hold still through the next re-render, and where in the + * pane to hold it. Set when the reader zooms: rescaling around the left edge + * of the scroll area would throw whatever they were reading off the screen, + * and re-opening at today would undo the scrolling they did to get there. + */ + const hold = useRef<{ ms: number; px: number } | null>(null); + + const zoom = (by: 1 | -1) => { + const el = scroller.current; + if (el !== null) { + // The middle of the view is what a reader is looking at, so that is what + // stays put. + const px = el.clientWidth / 2; + hold.current = { ms: min + ((el.scrollLeft + px) / dayWidth) * DAY, px }; + } + onZoom(stepDayWidth(dayWidth, by)); + }; + + // Before paint, so a zoom never shows a frame at the wrong offset. + useLayoutEffect(() => { + const el = scroller.current; + if (el === null) return; + const anchor = hold.current; + if (anchor !== null) { + hold.current = null; + el.scrollLeft = Math.max(0, x(anchor.ms) - anchor.px); + return; + } + // Open at today rather than at the far past: it is what they came for. + // Keyed on the rounded offset so it runs when the range changes, not every + // second — re-scrolling on each tick would fight the reader's own scrolling. + el.scrollTo({ left: openAt, behavior: "instant" }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openAt]); @@ -114,6 +149,9 @@ export function Timeline({ const months = monthBoundaries(min, max); const weeks = weekBoundaries(min, max); + // Every Monday is right at the default scale and illegible at the widest zoom + // out, where the dates would sit on top of each other. + const labelEvery = weekLabelStep(dayWidth); return ( <> @@ -122,13 +160,33 @@ export function Timeline({ calendar and cover the very dates it sends you back to. */}

One lane per game

- + +
+
+ zoom(-1)} + > + − + + zoom(1)} + > + + + +
+ + +
))} - {weeks.map((ms) => ( - - {dayLabel(ms)} - - ))} + {weeks.map((ms, i) => + i % labelEvery === 0 ? ( + + {dayLabel(ms)} + + ) : null, + )}
@@ -296,6 +356,38 @@ export function boardWindow( }; } +/** + * One step of the scale control. + * + * Labelled by what it does to the board rather than "zoom in" and "zoom out", + * which say what happens to the picture and leave the reader to work out what + * that means for the dates. + */ +function ScaleButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + /** * Fade a bar's edge where the truth extends past what is drawn: the left when * the event began before the view opens, the right when its end is unannounced. diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index aa8ff06..ac8316d 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -4,6 +4,7 @@ import type { Region } from "../../shared/schema.ts"; import { guessRegion } from "../../shared/time.ts"; import type { SortMode } from "./sort.ts"; import { KEYS, readJson, writeJson } from "./storage.ts"; +import { DEFAULT_DAY_WIDTH } from "./zoom.ts"; /** * Which of the two views the reader is looking at. @@ -62,6 +63,15 @@ export interface Prefs { * them and the choice is remembered from then on. */ view: View; + /** + * How wide one day is on the timeline, in px. + * + * Stored as the measurement rather than a step number, so the ladder in + * `state/zoom.ts` can change without silently rescaling boards that were set + * before it did. Read through `snapDayWidth`, which is what makes a value + * from an older export — or a corrupted one — land on something renderable. + */ + timelineDayWidth: number; /** * Whether to guess which events repeat daily from what the source printed. * Off leaves only the ones the reader marked themselves; it never discards a @@ -88,6 +98,7 @@ function defaults(): Prefs { focusGame: null, sort: "ending", view: "soon", + timelineDayWidth: DEFAULT_DAY_WIDTH, detectDaily: false, showCompleted: true, showIgnored: false, diff --git a/src/client/state/zoom.ts b/src/client/state/zoom.ts new file mode 100644 index 0000000..afe0495 --- /dev/null +++ b/src/client/state/zoom.ts @@ -0,0 +1,64 @@ +/** + * How far the timeline is zoomed in, expressed as the width of one day. + * + * A patch cycle is six weeks and a login campaign can run for months, so no + * single scale answers both "what am I in the middle of this week?" and "how do + * the next three months line up?". The reader picks. + * + * Pure, and its own module rather than a constant inside `Timeline`, because + * `prefs` stores the chosen value and the two must agree on what is valid. + */ + +/** The ladder, in px per day. Roughly a third wider at each step. */ +export const DAY_WIDTHS = [6, 9, 13, 20, 32, 48] as const; + +/** + * The scale the board opens at for a reader who has never touched the control. + * + * Thirteen px/day is a little over a quarter on a laptop and a patch cycle on a + * phone — dense enough that a bar's length reads as a duration rather than a + * dash, wide enough that most event names fit inside their own bar. + */ +export const DEFAULT_DAY_WIDTH = 13; + +/** + * The nearest valid scale to a stored number. + * + * `prefs` is a file on someone's device that an export/import round trip can + * carry between versions, so the ladder is allowed to change and a value off + * it must not render a board one pixel wide. Anything unusable falls back to + * the default rather than to the nearest edge — a corrupt value is not a + * preference. + */ +export function snapDayWidth(px: number): number { + if (!Number.isFinite(px) || px <= 0) return DEFAULT_DAY_WIDTH; + // `<=` over an ascending ladder means a value sitting exactly between two + // steps takes the wider one. Ties go to the more legible board. + return DAY_WIDTHS.reduce((best, step) => + Math.abs(step - px) <= Math.abs(best - px) ? step : best, + ); +} + +/** One step in or out, stopping at the ends of the ladder. */ +export function stepDayWidth(px: number, by: 1 | -1): number { + const at = DAY_WIDTHS.indexOf(snapDayWidth(px) as (typeof DAY_WIDTHS)[number]); + return DAY_WIDTHS[Math.min(Math.max(at + by, 0), DAY_WIDTHS.length - 1)] ?? DEFAULT_DAY_WIDTH; +} + +/** Whether there is anywhere further to go in that direction. */ +export function canStep(px: number, by: 1 | -1): boolean { + return stepDayWidth(px, by) !== snapDayWidth(px); +} + +/** + * How many weeks apart the dated ticks on the axis are. + * + * Every Monday is right at the default scale and unreadable at the widest zoom + * out, where a week is 42px and the labels would sit on top of each other. The + * gridlines stay weekly either way — they are hairlines and they carry the + * rhythm; it is only the dates that have to thin out. + */ +export function weekLabelStep(dayWidth: number): number { + const MIN_LABEL_GAP = 64; + return Math.max(1, Math.ceil(MIN_LABEL_GAP / (7 * dayWidth))); +} diff --git a/test/zoom.test.ts b/test/zoom.test.ts new file mode 100644 index 0000000..a21060a --- /dev/null +++ b/test/zoom.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { + canStep, + DAY_WIDTHS, + DEFAULT_DAY_WIDTH, + snapDayWidth, + stepDayWidth, + weekLabelStep, +} from "../src/client/state/zoom.ts"; + +/** + * The timeline's scale. + * + * A patch cycle is six weeks and a login campaign can run for months, so the + * reader picks how much time is on screen. What these pin down is the two ways + * that can go wrong: a stored value the ladder no longer contains, and an axis + * whose dates stop being readable once they are close enough together. + */ + +describe("snapDayWidth", () => { + test("a value on the ladder is left alone", () => { + for (const width of DAY_WIDTHS) expect(snapDayWidth(width)).toBe(width); + }); + + test("a value between steps lands on the nearest one", () => { + // An export written against a different ladder still opens on something + // close to what its reader chose. + expect(snapDayWidth(10)).toBe(9); + expect(snapDayWidth(7)).toBe(6); + expect(snapDayWidth(1000)).toBe(48); + // Exactly between two steps takes the wider one: ties go to the more + // legible board. + expect(snapDayWidth(11)).toBe(13); + }); + + test("an unusable value is the default, not the nearest edge", () => { + // A corrupt number is not a preference, and a board one pixel wide is not + // a scale anyone chose. + expect(snapDayWidth(0)).toBe(DEFAULT_DAY_WIDTH); + expect(snapDayWidth(-5)).toBe(DEFAULT_DAY_WIDTH); + expect(snapDayWidth(Number.NaN)).toBe(DEFAULT_DAY_WIDTH); + expect(snapDayWidth(Number.POSITIVE_INFINITY)).toBe(DEFAULT_DAY_WIDTH); + }); +}); + +describe("stepDayWidth", () => { + test("moves one step at a time", () => { + expect(stepDayWidth(13, 1)).toBe(20); + expect(stepDayWidth(13, -1)).toBe(9); + }); + + test("stops at the ends rather than wrapping", () => { + const widest = DAY_WIDTHS[DAY_WIDTHS.length - 1]!; + const narrowest = DAY_WIDTHS[0]!; + expect(stepDayWidth(widest, 1)).toBe(widest); + expect(stepDayWidth(narrowest, -1)).toBe(narrowest); + }); + + test("canStep says when a control has nowhere left to go", () => { + expect(canStep(DAY_WIDTHS[0]!, -1)).toBe(false); + expect(canStep(DAY_WIDTHS[0]!, 1)).toBe(true); + expect(canStep(DAY_WIDTHS[DAY_WIDTHS.length - 1]!, 1)).toBe(false); + }); +}); + +describe("weekLabelStep", () => { + test("every Monday is dated at the default scale and closer in", () => { + expect(weekLabelStep(DEFAULT_DAY_WIDTH)).toBe(1); + expect(weekLabelStep(48)).toBe(1); + }); + + test("dates thin out rather than overlapping when zoomed out", () => { + // At six px a day a week is 42px and "18 Aug" is wider than that. + expect(weekLabelStep(6)).toBeGreaterThan(1); + expect(weekLabelStep(6) * 7 * 6).toBeGreaterThanOrEqual(64); + }); + + test("never asks for a label every zero weeks", () => { + expect(weekLabelStep(1000)).toBe(1); + }); +});