feat: sort by what you're partway through

"What runs out first" is the question this app exists for, but it is not
the only one a reader arrives with. The other is "what was I in the
middle of?", and until now the list could not answer it.

Two modes, toggled from the list's own header rather than from settings,
because ordering is something you reach for while looking at a list.
Ticking a day off a daily counts as doing it without the reader also
setting the status — the tick already said so.

Sorting only ever groups. Both modes fall back to endingSoonestFirst
inside a group and live still beats upcoming, so choosing an order can
never cost the reader the deadline order the product is for.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 21:18:35 +02:00
co-authored by Claude Opus 5
parent ccc5d369bd
commit 8e42afdd7a
4 changed files with 224 additions and 4 deletions
+49
View File
@@ -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<Activity, number> = { 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<T extends { event: { id: string }; clock: EventClock }>(
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);
};
}
+4
View File
@@ -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,