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:
co-authored by
Claude Opus 5
parent
9dde82801a
commit
3db93a1ccf
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user