feat(timeline): offer both readings of a board with the future on it

"In their own group" is the shape of a patch — what is on now, and what
is queued behind it. "Mixed in" is the question a Gantt chart exists for:
what runs out first, whether or not it has opened. On real data that is
not a cosmetic difference — mixed, five events currently running sort
below an Arknights rerun that has not started, because it closes before
they do.

Which is why it is a pair of pills and not a checkbox: neither answer is
the absence of the other, and "mixed in" is a different order rather than
the heading switched off. It sits under the row that puts the future on
the board at all, and only while that row is ticked — a choice about
arranging unstarted events is unanswerable with none of them on screen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 06:05:58 +02:00
co-authored by Claude Opus 5
parent 9dde82801a
commit 3db93a1ccf
5 changed files with 145 additions and 3 deletions
+1
View File
@@ -552,6 +552,7 @@ export function App() {
// shorter list, so it can say how many are waiting when there is // shorter list, so it can say how many are waiting when there is
// nothing else left to draw. The switch is in settings. // nothing else left to draw. The switch is in settings.
showUpcoming={prefs.timelineUpcoming} showUpcoming={prefs.timelineUpcoming}
splitUpcoming={prefs.timelineSplitUpcoming}
onOpen={setOpenId} onOpen={setOpenId}
isDone={isDone} isDone={isDone}
/> />
+55
View File
@@ -21,6 +21,28 @@ const THEMES: Array<{ id: ThemeChoice; label: string }> = [
{ id: "system", label: "System" }, { id: "system", label: "System" },
]; ];
/**
* The two readings of a board with the future on it, and both are right for
* somebody — see PRD F1.
*
* Kept as a pair of pills rather than a second checkbox because neither answer
* is the absence of the other: "mixed in" is a different order, not a heading
* switched off. A checkbox would name one of them and leave the other as
* whatever is left over.
*/
const SPLITS: Array<{ split: boolean; label: string; hint: string }> = [
{
split: true,
label: "In their own group",
hint: "Each lane runs out, then a \u201cNot started yet\u201d heading and what is queued behind it.",
},
{
split: false,
label: "Mixed in",
hint: "One deadline order, started or not — so something opening Friday and closing Sunday sits above an event running until October.",
},
];
export function Controls({ export function Controls({
games, games,
prefs, prefs,
@@ -160,6 +182,39 @@ export function Controls({
</span> </span>
</label> </label>
{/* Only while there is something to arrange. A choice about how
unstarted events sit on the board is unanswerable when none
are on it, and offering it anyway is a control that does
nothing — the stored answer is kept either way, so switching
the row above back on restores it rather than a default. */}
{prefs.timelineUpcoming && (
<div className="ml-6 flex flex-col gap-1.5">
<div className="flex gap-1.5">
{SPLITS.map((s) => (
<button
key={String(s.split)}
type="button"
onClick={() =>
onUpdate({ timelineSplitUpcoming: s.split })
}
aria-pressed={prefs.timelineSplitUpcoming === s.split}
className={`rounded-full border px-3 py-1 text-[0.6875rem] font-medium transition-colors ${
prefs.timelineSplitUpcoming === s.split
? "border-ink/70 text-ink"
: "border-hairline text-faint hover:text-muted"
}`}
>
{s.label}
</button>
))}
</div>
<p className="max-w-xs text-xs leading-relaxed text-faint">
{SPLITS.find((s) => s.split === prefs.timelineSplitUpcoming)
?.hint}
</p>
</div>
)}
{/* Detection reads the source's wording and is wrong in both {/* Detection reads the source's wording and is wrong in both
directions, so it ships off and says so. Off leaves only the directions, so it ships off and says so. Off leaves only the
events the reader marked, and discards nothing — every mark and events the reader marked, and discards nothing — every mark and
+17 -2
View File
@@ -98,6 +98,7 @@ export function Timeline({
group, group,
onGroup, onGroup,
showUpcoming, showUpcoming,
splitUpcoming,
onOpen, onOpen,
isDone, isDone,
}: { }: {
@@ -124,6 +125,18 @@ export function Timeline({
* decides what is on it at all. * decides what is on it at all.
*/ */
showUpcoming: boolean; showUpcoming: boolean;
/**
* Whether those unstarted events keep to their own block under a heading, or
* sit in one deadline order with the running ones
* (`prefs.timelineSplitUpcoming`, and the switch is in settings beside the
* one above).
*
* Mixed is not merely the heading switched off: the orders this board is
* given all hold unstarted rows behind running ones, so dropping the label
* alone would leave the same block with nothing explaining it. `lanes.ts`
* re-sorts instead, and this only decides whether the heading is drawn.
*/
splitUpcoming: boolean;
onOpen: (id: string) => void; onOpen: (id: string) => void;
/** /**
* Asked rather than derived from the progress store: an entry exists there * Asked rather than derived from the progress store: an entry exists there
@@ -201,7 +214,7 @@ export function Timeline({
); );
} }
const lanes = timelineLanes(plotted, group); const lanes = timelineLanes(plotted, group, splitUpcoming);
const marks = startMarkers(plotted, x); const marks = startMarkers(plotted, x);
const months = monthBoundaries(min, max); const months = monthBoundaries(min, max);
@@ -351,7 +364,9 @@ export function Timeline({
{lanes.map((lane) => { {lanes.map((lane) => {
const heading = lane.game === null ? null : gameMeta(lane.game); const heading = lane.game === null ? null : gameMeta(lane.game);
// Where this lane stops running and starts being scheduled. // Where this lane stops running and starts being scheduled.
const breakAt = splitAt(lane.rows); // Mixed in, there is no such place — the rows are one deadline
// queue and a heading would be pointing at the middle of it.
const breakAt = splitUpcoming ? splitAt(lane.rows) : -1;
return ( return (
<div key={lane.id}> <div key={lane.id}>
{/* On its own line and pinned to the left edge, so the lane {/* On its own line and pinned to the left edge, so the lane
+27
View File
@@ -93,6 +93,33 @@ describe("Controls: what am I allowed to look at", () => {
expect(on.filter(Boolean)).toHaveLength(2); expect(on.filter(Boolean)).toHaveLength(2);
}); });
test("how unstarted events sit on the board is offered only when they are", () => {
// A choice about arranging them is unanswerable with none on the board,
// and a control that changes nothing visible is worse than none.
expect(render(PREFS)).not.toContain("Mixed in");
const on = render({ ...PREFS, timelineUpcoming: true });
expect(on).toContain("In their own group");
expect(on).toContain("Mixed in");
});
test("it is a pair of answers, not one answer and its absence", () => {
// "Mixed in" is a different order, not a heading switched off, so both
// states name themselves and the panel says which is on.
const split = render({ ...PREFS, timelineUpcoming: true });
const mixed = render({
...PREFS,
timelineUpcoming: true,
timelineSplitUpcoming: false,
});
const pressed = (html: string) =>
[...html.matchAll(/aria-pressed="true"[^>]*>([^<]+)</g)].map((m) => m[1]);
expect(pressed(split)).toContain("In their own group");
expect(pressed(mixed)).toContain("Mixed in");
// And the line under them describes the answer that is actually on.
expect(mixed).toContain("One deadline order");
expect(split).not.toContain("One deadline order");
});
test("the ignored row appears only once something is ignored", () => { test("the ignored row appears only once something is ignored", () => {
// Nothing to restore means nothing to offer — the row would be a filter // Nothing to restore means nothing to offer — the row would be a filter
// over an empty set. // over an empty set.
+45 -1
View File
@@ -262,11 +262,23 @@ describe("Timeline stacking", () => {
group={group} group={group}
onGroup={() => {}} onGroup={() => {}}
showUpcoming={false} showUpcoming={false}
splitUpcoming
onOpen={() => {}} onOpen={() => {}}
isDone={() => false} isDone={() => false}
/>, />,
); );
test("mixed in, lane mode re-sorts rather than keeping the block", () => {
// The one place `timelineLanes` is allowed to reorder a lane. Leaving the
// given order alone would draw exactly the block it was told not to, minus
// the heading that explained it.
// Given live-first, as every sort this board can be handed produces. B has
// the nearer end (48h against 100h) but has not opened yet.
const given = [row("A", "hsr", 100), upcoming("B", "hsr", 24, 24)];
expect(timelineLanes(given, "game", true)[0]?.rows[0]?.event.title).toBe("A");
expect(timelineLanes(given, "game", false)[0]?.rows[0]?.event.title).toBe("B");
});
test("both stackings plot every event", () => { test("both stackings plot every event", () => {
for (const group of ["game", "ending"] as const) { for (const group of ["game", "ending"] as const) {
const html = board(group); const html = board(group);
@@ -306,7 +318,12 @@ describe("Timeline: events that have not started", () => {
upcoming("Long Way Round", "wuwa", 30 * 24), upcoming("Long Way Round", "wuwa", 30 * 24),
]; ];
const board = (showUpcoming: boolean, all = rows, group: "game" | "ending" = "ending") => const board = (
showUpcoming: boolean,
all = rows,
group: "game" | "ending" = "ending",
splitUpcoming = true,
) =>
render( render(
<Timeline <Timeline
rows={all} rows={all}
@@ -316,6 +333,7 @@ describe("Timeline: events that have not started", () => {
group={group} group={group}
onGroup={() => {}} onGroup={() => {}}
showUpcoming={showUpcoming} showUpcoming={showUpcoming}
splitUpcoming={splitUpcoming}
onOpen={() => {}} onOpen={() => {}}
isDone={() => false} isDone={() => false}
/>, />,
@@ -382,6 +400,32 @@ describe("Timeline: events that have not started", () => {
expect(html.split("Not started yet")).toHaveLength(4); expect(html.split("Not started yet")).toHaveLength(4);
}); });
test("mixed in, there is no block to head and no heading", () => {
// Not the heading switched off: the rows are one deadline queue, so a
// label would be pointing at the middle of it.
const html = board(true, rows, "ending", false);
expect(html).toContain("Frost Parade");
expect(html).not.toContain("Not started yet");
});
test("mixed in, a nearer deadline wins whether or not it has opened", () => {
// The whole point of the option, and the one thing the split order can
// never show. Frost Parade opens in 3 days and closes 10 days after that;
// Closing Ceremony is running now until 100 hours from now — so it is
// still the nearer deadline, and Frost Parade sits under it rather than
// behind every running row.
const near = upcoming("Quick Turnaround", "hsr", 24, 24);
const html = board(true, [row("Closing Ceremony", "genshin", 100), near], "ending", false);
expect(html.indexOf("Quick Turnaround")).toBeLessThan(
html.indexOf("Closing Ceremony"),
);
// Split, the same two rows go the other way round.
const kept = board(true, [row("Closing Ceremony", "genshin", 100), near], "ending", true);
expect(kept.indexOf("Closing Ceremony")).toBeLessThan(
kept.indexOf("Quick Turnaround"),
);
});
test("no heading where nothing is waiting", () => { test("no heading where nothing is waiting", () => {
// A label with nothing under it is a section that does not exist. // A label with nothing under it is a section that does not exist.
expect(board(true, [row("Closing Ceremony", "genshin", 100)])).not.toContain( expect(board(true, [row("Closing Ceremony", "genshin", 100)])).not.toContain(