From 6029e9ef107338f54efdc8071c2200a3e2bab68b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 19 Aug 2026 05:59:43 +0200 Subject: [PATCH] feat(timeline): let a lane be one deadline queue, started or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `endingSoonestFirst` holds every unstarted event behind every running one. That is right for a checklist — you cannot do a thing that has not opened — and it is one answer of two for a board, which is asked what runs out first. An event opening on Friday and closing on Sunday is a nearer deadline than one running now until October, and that order can never show it. So `byDeadline` is the same comparator with that clause removed, and `timelineLanes` takes a `split` flag choosing between them. It defaults to the old behaviour, so nothing moves yet. The flag is also the one case where lane mode reorders inside a lane, which the module otherwise refuses to do. Deliberate: the given order is live-first whatever the reader chose in the list, so honouring it would draw exactly the block this was asked not to draw. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/state/lanes.ts | 22 +++++++++++++++++++--- src/shared/time.ts | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/client/state/lanes.ts b/src/client/state/lanes.ts index 13435f5..9feb3d7 100644 --- a/src/client/state/lanes.ts +++ b/src/client/state/lanes.ts @@ -1,5 +1,5 @@ import type { LaneId } from "../../shared/custom.ts"; -import { endingSoonestFirst, type EventClock } from "../../shared/time.ts"; +import { byDeadline, endingSoonestFirst, type EventClock } from "../../shared/time.ts"; /** * How the timeline is stacked: a lane per game, or every game together in @@ -65,19 +65,35 @@ export interface Lane { * Lane mode leaves the order it was given alone. The rows arrive sorted by * whatever the reader chose in the list, and grouping them by game is not a * licence to re-sort inside a game. + * + * `split` is the exception to that last sentence, and deliberately so. Both + * orders above hold every unstarted event behind every running one, which is + * the segregation the board's "Not started yet" heading names — so a reader who + * asks for them mixed in is asking for exactly that clause to be dropped, and + * `byDeadline` is the same comparator with it gone. It applies in **both** + * modes, lane mode included: leaving a lane's given order alone there would + * produce the block it was told not to draw, minus the heading that explained + * it, which is the worst of both answers. */ export function timelineLanes( rows: readonly T[], mode: TimelineGroup, + split = true, ): Array> { + const order = split ? endingSoonestFirst : byDeadline; + if (mode === "ending") { if (rows.length === 0) return []; - return [{ id: "all", game: null, rows: [...rows].sort(endingSoonestFirst) }]; + return [{ id: "all", game: null, rows: [...rows].sort(order) }]; } const byGame = new Map(); for (const row of rows) { byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); } - return [...byGame].map(([game, laneRows]) => ({ id: game, game, rows: laneRows })); + return [...byGame].map(([game, laneRows]) => ({ + id: game, + game, + rows: split ? laneRows : [...laneRows].sort(byDeadline), + })); } diff --git a/src/shared/time.ts b/src/shared/time.ts index cb1fd09..16c6980 100644 --- a/src/shared/time.ts +++ b/src/shared/time.ts @@ -344,6 +344,30 @@ export function endingSoonestFirst( return a.clock.msRemaining - b.clock.msRemaining; } +/** + * Sort key: soonest deadline first, and nothing else. + * + * The other half of the timeline's "put them all together" (PRD F1). Where + * `endingSoonestFirst` holds every unstarted event behind every running one — + * which is what a *checklist* wants, since you cannot do a thing that has not + * opened — this asks the one question a Gantt board is for: what runs out + * first. An event opening on Friday and closing on Sunday is a nearer deadline + * than one running now until October, and a reader looking at a calendar rather + * than a to-do list is entitled to see it in that order. + * + * The `endsAt: null` rule is the same in both: an unannounced end is real but is + * not a deadline, so it sorts behind every dated row rather than claiming a + * place in the queue. + */ +export function byDeadline( + a: { clock: EventClock }, + b: { clock: EventClock }, +): number { + if (a.clock.msRemaining === null) return b.clock.msRemaining === null ? 0 : 1; + if (b.clock.msRemaining === null) return -1; + return a.clock.msRemaining - b.clock.msRemaining; +} + /** * A plain-language caption for an event's window. *