diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19d5917..cf2bc2a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -100,7 +100,7 @@ src/ EventRow.tsx row + meter + caption (F2, F3) Meter.tsx the depletion meter Legend.tsx what the bars and colours mean - Timeline.tsx calendar lanes (F1) + Timeline.tsx the board: pinned axis, lanes, bars (F1) EventDetail.tsx detail sheet, ignore action (F9) ProgressControls status, effort, note (F12) Dailies.tsx today's strip, per-game reset clocks diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md index b5f5e38..3ce6426 100644 --- a/docs/FEEDBACK.md +++ b/docs/FEEDBACK.md @@ -307,8 +307,10 @@ diagnosis in each item still holds — what changed is whether it has been acted | P1b Calendar → Timeline | **Done.** The tab reads "Timeline" | | P1c more games | **Done, four of them.** Infinity Nikki, Persona 5: The Phantom X, Reverse: 1999, Blue Archive — one commit each. Of the games named in the thread, Azur Lane and Umamusume are **declined on conduct** rather than pending, and the declined and cleared-but-unbuilt candidates are recorded in `AGENTS.md` § Scraping conduct so they are not re-litigated | | P1c roadmap in the colophon | **Not done** | -| P2 colour and screenshot | **Not done** | +| P2 colour and screenshot | **Layout done** (2026-08-18), colour untouched. The palette was left alone deliberately — the two-axis system was not what made the screenshot read as unfinished, a dense single column on a wide screen was. Past `lg` the page is now a pinned deadline rail beside the lists, the timeline is a board with its axis and names pinned, and the footer and settings are columns. The screenshot still wants reshooting | | P2 custom events | **Done.** PRD F13 first, then the code; `mygame:` / `myevent:` key spaces, in the export | -So the outstanding work is P1b's three client changes, the colophon roadmap, the colour pass, and -P0's build assertion — the cheapest items on the list are the ones still open. +So the outstanding work is the colophon roadmap, P0's build assertion, and a reshoot of the promo +screenshot. P1b is closed. The colour pass is open in name only: the palette was examined and left +alone with the reasoning above, so what is left of P2 is the screenshot, which is now worth taking +from a wide window. diff --git a/docs/PRD.md b/docs/PRD.md index 68efebd..88cbc79 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -63,7 +63,7 @@ no stored key. A game may have several sources; see `docs/INGESTION.md` § Three ### Features -**F1 — Calendar view (default).** +**F1 — Timeline view.** A horizontal timeline, one lane per game, spanning a scrollable date range with "today" pinned as a vertical marker. Each event is a bar from `startsAt` to `endsAt`. Bars are colored by game, and completed events render at reduced opacity with a check. Clicking a bar opens a detail panel with @@ -72,22 +72,29 @@ title, type, exact start/end in the user's local timezone, source link, and a co An event with `endsAt: null` renders as a bar with a frayed right edge and the label "end date unknown" — it must be visually distinct from an event that ends far in the future. +It is a board rather than a stretch of page: its own pane, scrolling in both directions, with the +date axis pinned to the top and every name — the lane's and the event's — pinned to the left edge. +Those three used to scroll away together, which made a wide window worse rather than better: more +calendar on screen, and nothing left saying which day, whose game, or which event was being read. A +six-week bar starts weeks off-screen, so a name that rides off with its own start date leaves a +coloured rectangle behind. + **F2 — Ends-soonest list.** A flat list of all *currently running* events sorted ascending by end date, with a relative countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the view that justifies the app, and it is one tap from the timeline. -**The list is capped and offers the rest.** Two games already run to twenty-one live events, and a -reader who tried exactly that said the list stopped being usable. Each section shows a handful with -an explicit "show all N". This truncates the *view* only: the order is untouched, and the rows below -the cut are still counted in the header, still on the timeline, and one tap away. - **Which view opens is the reader's answer, not ours.** This spec said "calendar (default)" and the app shipped opening on the list; both were a decision made on the reader's behalf and then forgotten on every reload. So the first run asks (F8) and the answer is stored in `prefs.view`. The list is what the question ships pre-answered with — a reader cannot choose between two layouts they have not seen, and it is the view that answers "what expires next" in one look. +**The list is capped and offers the rest.** Two games already run to twenty-one live events, and a +reader who tried exactly that said the list stopped being usable. Each section shows a handful with +an explicit "show all N". This truncates the *view* only: the order is untouched, and the rows below +the cut are still counted in the header, still on the timeline, and one tap away. + **F3 — Mark completed.** A toggle on every event, in both views. State is written to `localStorage` immediately and optimistically — there is no server round trip and no failure case. Completed events stay visible diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index d4a42f8..3f10343 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -7,6 +7,15 @@ import { URGENCY_COLOR } from "./Meter.tsx"; const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once +/** + * How far in from the left edge of the board a pinned label sits. + * + * Both the lane names and the event names stick here rather than riding off + * with their own start dates. A six-week event begins weeks off-screen, and a + * bar whose name scrolled away with its start date is a coloured rectangle. + */ +const PIN = 8; + /** * A sliver of time before now, so the "now" rule reads as a line in the view * rather than merging with the border. @@ -19,12 +28,21 @@ const HALF_DAY_LEAD = 12 * 60 * 60 * 1000; */ const PAST_LEAD = 7 * DAY; +/** Where the now rule sits when the board opens: a little in from the edge. */ +const OPEN_INSET = 28; + /** * One lane per game, bars spanning start→end, today pinned as a rule. * * The quiet view. The ending-soon list carries the page's boldness, so this * stays flat and legible: no gradients, no rounded chrome, just position and * length doing the work. + * + * It is a board rather than a stretch of page — its own pane, scrolling in both + * directions, with the date axis pinned to the top and every name pinned to the + * left. All three used to scroll away together, which is what made a wide + * window worse rather than better: more calendar on screen, and nothing left + * saying which day, whose game, or which event you were looking at. */ export function Timeline({ rows, @@ -54,18 +72,22 @@ export function Timeline({ // off-screen; clamping to now made the past unreachable. This does both. const min = Math.min(...starts, now) - PAST_LEAD; const max = Math.max(...ends, now) + 2 * DAY; - // Where "now" sits, so the container can be scrolled there on open. - const nowOffset = ((now - HALF_DAY_LEAD - min) / DAY) * DAY_WIDTH; const totalDays = Math.ceil((max - min) / DAY); - const width = totalDays * DAY_WIDTH; + const chartWidth = totalDays * DAY_WIDTH; + /** One coordinate space for everything: bars, gridlines and the now rule. */ const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH; - // Open at today rather than at the far past. 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. - const openAt = Math.round(nowOffset); + // 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 + // history. Keyed on the rounded offset so it runs when the range changes, + // not every second: re-scrolling on each tick would fight the reader. + const openAt = Math.round(Math.max(0, x(now - HALF_DAY_LEAD) - OPEN_INSET)); + const jumpToNow = (behavior: ScrollBehavior) => + scroller.current?.scrollTo({ left: openAt, behavior }); + useEffect(() => { - scroller.current?.scrollTo({ left: openAt, behavior: "instant" }); + jumpToNow("instant"); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [openAt]); if (rows.length === 0) { @@ -81,91 +103,162 @@ export function Timeline({ byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); } - const monthTicks = monthBoundaries(min, max); + const months = monthBoundaries(min, max); + const weeks = weekBoundaries(min, max); return ( -
-
- {/* Month rule, so a bar's absolute position means something. */} -
- {monthTicks.map((t) => ( - - {t.label} - - ))} -
- -
+ {/* The board's own header. The jump control lives out here rather than + floating over the chart: pinned inside, it would sit on top of the + calendar and cover the very dates it sends you back to. */} +
+

One lane per game

+
+ Jump to today + +
-
- {[...byGame.entries()].map(([gameId, events]) => { - const game = gameMeta(gameId); - return ( -
-

- {game.short} -

-
- {events.map(({ event, clock }) => { - const unknownEnd = clock.endsMs === null; - // Only clipped if it began before the rendered window, - // which now reaches a week past the oldest running event — - // so in practice bars show their real start and the fade is - // reserved for genuinely truncated ones. - const clippedStart = clock.startsMs < min; - const left = Math.max(x(clock.startsMs), 0); - const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY); - const done = isDone(event.id); - return ( - - ); - })} +
+
+ {/* Gridlines first, so everything else paints over them. */} +
+ {weeks.map((ms) => ( + + ))} + {months.slice(1).map((m) => ( + + ))} +
+ + {/* Now: the one rule that has to be findable from anywhere. */} +
+ + now + +
+ + {/* The axis: months above, week dates below, pinned to the top. */} +
+ {months.map((m) => ( + + {m.label} + + ))} + {weeks.map((ms) => ( + + {dayLabel(ms)} + + ))} +
+ +
+ {[...byGame.entries()].map(([gameId, events]) => { + const game = gameMeta(gameId); + return ( +
+ {/* On its own line and pinned to the left edge, so the lane + keeps its name at any scroll position without a frozen + column standing on top of the calendar. */} +

+ {game.short} +

+ +
+ {events.map(({ event, clock }) => { + const unknownEnd = clock.endsMs === null; + // Only clipped if it began before the rendered window, + // which reaches a week past the oldest running event — so + // in practice bars show their real start and the fade is + // reserved for genuinely truncated ones. + const clippedStart = clock.startsMs < min; + const left = Math.max(x(clock.startsMs), 0); + const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY); + const done = isDone(event.id); + return ( + + ); + })} +
-
- ); - })} + ); + })} +
-
+ ); } @@ -182,6 +275,34 @@ function edgeMask(clippedStart: boolean, unknownEnd: boolean): string | undefine return undefined; } +/** `18 Aug`, in the reader's own locale, for the week ticks. */ +function dayLabel(ms: number): string { + return new Date(ms).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + }); +} + +/** + * Every Monday in range. + * + * Weeks are the unit these schedules are actually written in — a patch is six + * of them — and a tick every seven days is the densest grid that still leaves + * room for a date on it. + */ +function weekBoundaries(min: number, max: number): number[] { + const d = new Date(min); + d.setUTCHours(0, 0, 0, 0); + // 0 is Sunday; step forward to the next Monday. + d.setUTCDate(d.getUTCDate() + ((8 - d.getUTCDay()) % 7)); + const out: number[] = []; + while (d.getTime() <= max) { + out.push(d.getTime()); + d.setUTCDate(d.getUTCDate() + 7); + } + return out; +} + function monthBoundaries(min: number, max: number) { const short = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "short" });