diff --git a/src/client/App.tsx b/src/client/App.tsx index 3cb4f68..afe927a 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -15,7 +15,8 @@ import { useMarkSet } from "./state/useMarkSet.ts"; import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { usePrefs } from "./state/usePrefs.ts"; -import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts"; +import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; +import { clockFor, DAY, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily } from "../shared/daily.ts"; import type { GameId } from "../shared/schema.ts"; @@ -70,6 +71,20 @@ export function App() { // this question a lot, so keep a cheap shorthand. const isDone = (id: string) => prog.progress[id]?.status === "done"; + /** + * How far into an event the reader is, for ordering only. + * + * Ticking a day off a repeating event counts as "doing it" without them + * having to also set the status — the tick already said so, and asking twice + * is how a sort ends up lying about what you were in the middle of. + */ + const activityOf = (id: string): Activity => { + const status = prog.progress[id]?.status; + if (status === "done") return "done"; + if (status === "doing") return "doing"; + return daily.daysFor(id).length > 0 ? "doing" : "idle"; + }; + /** Today's state for a repeating event, or undefined if it does not repeat. */ const dailyBadge = (row: RowEvent): DailyBadge | undefined => { if (!isDaily(row.event)) return undefined; @@ -128,12 +143,15 @@ export function App() { // — that is the whole point of ignoring one. .filter((r) => prefs.showIgnored || ignored.marks[r.event.id] === undefined) .filter((r) => prefs.showCompleted || !isDone(r.event.id)) - .sort(endingSoonestFirst), + // Sorting only ever groups: both modes fall back to soonest-ending + // inside a group, so choosing one never costs the deadline order. + .sort(compareRows(prefs.sort, activityOf)), [ allRows, prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, + prefs.sort, prog.progress, daily.logs, ignored.marks, @@ -254,6 +272,14 @@ export function App() { )}` : undefined } + action={ + visible.length > 1 ? ( + update({ sort })} + /> + ) : undefined + } > {live.map((row) => ( 0 && ( -
+
1 ? ( + update({ sort })} + /> + ) : undefined + } + > {upcoming.map((row) => (

{title}

- {hint !== undefined &&

{hint}

} + {action ?? (hint !== undefined &&

{hint}

)}
{legend === true && }
    {children}
@@ -393,6 +434,45 @@ function Section({ ); } +/** + * Order the list by deadline, or by what the reader is partway through. + * + * Sits in the list's own header rather than down in settings: ordering is a + * thing you reach for while looking at the list, not a preference you go and + * configure. + */ +function SortControl({ + value, + onChange, +}: { + value: SortMode; + onChange: (mode: SortMode) => void; +}) { + return ( +
+ {SORT_MODES.map((mode) => { + const on = value === mode.id; + return ( + + ); + })} +
+ ); +} + function exportProgress( progress: Record, daily: DailyLogMap, diff --git a/src/client/state/sort.ts b/src/client/state/sort.ts new file mode 100644 index 0000000..4418a62 --- /dev/null +++ b/src/client/state/sort.ts @@ -0,0 +1,49 @@ +import { endingSoonestFirst, type EventClock } from "../../shared/time.ts"; + +/** + * How the list is ordered. + * + * "ending" is the product's thesis — what runs out first. "doing" answers the + * other question a reader arrives with, which is "what was I in the middle + * of?", and is a strictly weaker sort: it only groups, and inside every group + * the deadline order is preserved. + */ +export type SortMode = "ending" | "doing"; + +export const SORT_MODES: Array<{ id: SortMode; label: string; hint: string }> = [ + { id: "ending", label: "Ending soonest", hint: "What runs out first" }, + { id: "doing", label: "Doing first", hint: "What you're partway through" }, +]; + +/** + * What the reader is doing with an event, as far as ordering cares. + * + * Deliberately coarser than the stored status: ticking a day off a daily + * checklist is evidence you are mid-way through something just as much as + * setting the status is, and the reader should not have to say it twice. + */ +export type Activity = "doing" | "idle" | "done"; + +const RANK: Record = { doing: 0, idle: 1, done: 2 }; + +/** + * Comparator for a list of rows. + * + * Both modes fall back to `endingSoonestFirst`, so an ordering change never + * costs the reader the deadline order they rely on — it only decides which + * block a row lands in. + */ +export function compareRows( + mode: SortMode, + activityOf: (id: string) => Activity, +): (a: T, b: T) => number { + if (mode === "ending") return endingSoonestFirst; + return (a, b) => { + // Live before upcoming, always. You cannot be partway through something + // that has not started, so activity must not lift a future event above a + // running one. + if (a.clock.upcoming !== b.clock.upcoming) return a.clock.upcoming ? 1 : -1; + const delta = RANK[activityOf(a.event.id)] - RANK[activityOf(b.event.id)]; + return delta !== 0 ? delta : endingSoonestFirst(a, b); + }; +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 5ef9886..11c4e98 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -1,12 +1,15 @@ import { useCallback, useEffect, useState } from "react"; import type { GameId, Region } from "../../shared/schema.ts"; import { guessRegion } from "../../shared/time.ts"; +import type { SortMode } from "./sort.ts"; import { KEYS, readJson, writeJson } from "./storage.ts"; export interface Prefs { region: Region; /** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */ hiddenGames: GameId[]; + /** How the list is ordered. Deadline order is the default and the fallback. */ + sort: SortMode; showCompleted: boolean; /** Reveal events the reader has ignored, so they can be restored. */ showIgnored: boolean; @@ -20,6 +23,7 @@ function defaults(): Prefs { return { region: guessRegion(), hiddenGames: [], + sort: "ending", showCompleted: true, showIgnored: false, regionConfirmed: false, diff --git a/test/sort.test.ts b/test/sort.test.ts new file mode 100644 index 0000000..5f0881b --- /dev/null +++ b/test/sort.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { compareRows, type Activity } from "../src/client/state/sort.ts"; +import { DAY, type EventClock } from "../src/shared/time.ts"; + +const NOW = Date.parse("2026-08-15T12:00:00Z"); + +function row(id: string, msRemaining: number | null, upcoming = false) { + const clock: EventClock = { + startsMs: upcoming ? NOW + DAY : NOW - DAY, + endsMs: msRemaining === null ? null : NOW + msRemaining, + msRemaining, + progress: 0.5, + urgency: "near", + live: !upcoming, + upcoming, + ended: false, + }; + return { event: { id }, clock }; +} + +const ids = (rows: Array<{ event: { id: string } }>) => rows.map((r) => r.event.id); + +/** Everything is idle unless the test says otherwise. */ +const activity = (map: Record) => (id: string) => + map[id] ?? "idle"; + +describe("compareRows", () => { + test("deadline order is the default and ignores activity", () => { + const rows = [row("late", 5 * DAY), row("soon", 1 * DAY)]; + const sorted = [...rows].sort( + compareRows("ending", activity({ late: "doing" })), + ); + expect(ids(sorted)).toEqual(["soon", "late"]); + }); + + test("doing first pulls what you're partway through to the top", () => { + const rows = [row("a", 1 * DAY), row("b", 5 * DAY), row("c", 9 * DAY)]; + const sorted = [...rows].sort(compareRows("doing", activity({ c: "doing" }))); + expect(ids(sorted)).toEqual(["c", "a", "b"]); + }); + + test("finished events sink below untouched ones", () => { + const rows = [row("done", 1 * DAY), row("fresh", 8 * DAY)]; + const sorted = [...rows].sort( + compareRows("doing", activity({ done: "done" })), + ); + expect(ids(sorted)).toEqual(["fresh", "done"]); + }); + + test("deadline order survives inside every group", () => { + // Grouping is all this mode does. If it also scrambled the deadlines it + // would cost the reader the one ordering the product exists for. + const rows = [ + row("doing-late", 6 * DAY), + row("idle-late", 7 * DAY), + row("doing-soon", 2 * DAY), + row("idle-soon", 3 * DAY), + ]; + const sorted = [...rows].sort( + compareRows("doing", activity({ "doing-late": "doing", "doing-soon": "doing" })), + ); + expect(ids(sorted)).toEqual([ + "doing-soon", + "doing-late", + "idle-soon", + "idle-late", + ]); + }); + + test("upcoming events stay after live ones in both modes", () => { + // Even marked "doing": you cannot be partway through an event that has not + // started, and a future one displacing a running one would be wrong. + const rows = [row("upcoming", 20 * DAY, true), row("live", 9 * DAY)]; + for (const mode of ["ending", "doing"] as const) { + const sorted = [...rows].sort( + compareRows(mode, activity({ upcoming: "doing" })), + ); + expect(ids(sorted)).toEqual(["live", "upcoming"]); + } + }); + + test("an unknown end sorts last among live events, as it always did", () => { + const rows = [row("unknown", null), row("known", 30 * DAY)]; + const sorted = [...rows].sort(compareRows("ending", activity({}))); + expect(ids(sorted)).toEqual(["known", "unknown"]); + }); +});