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:
co-authored by
Claude Opus 5
parent
ccc5d369bd
commit
8e42afdd7a
+84
-4
@@ -15,7 +15,8 @@ import { useMarkSet } from "./state/useMarkSet.ts";
|
|||||||
import { useProgress } from "./state/useProgress.ts";
|
import { useProgress } from "./state/useProgress.ts";
|
||||||
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
|
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
|
||||||
import { usePrefs } from "./state/usePrefs.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 { dailySummary, isDaily } from "../shared/daily.ts";
|
||||||
import type { GameId } from "../shared/schema.ts";
|
import type { GameId } from "../shared/schema.ts";
|
||||||
|
|
||||||
@@ -70,6 +71,20 @@ export function App() {
|
|||||||
// this question a lot, so keep a cheap shorthand.
|
// this question a lot, so keep a cheap shorthand.
|
||||||
const isDone = (id: string) => prog.progress[id]?.status === "done";
|
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. */
|
/** Today's state for a repeating event, or undefined if it does not repeat. */
|
||||||
const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
|
const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
|
||||||
if (!isDaily(row.event)) return undefined;
|
if (!isDaily(row.event)) return undefined;
|
||||||
@@ -128,12 +143,15 @@ export function App() {
|
|||||||
// — that is the whole point of ignoring one.
|
// — that is the whole point of ignoring one.
|
||||||
.filter((r) => prefs.showIgnored || ignored.marks[r.event.id] === undefined)
|
.filter((r) => prefs.showIgnored || ignored.marks[r.event.id] === undefined)
|
||||||
.filter((r) => prefs.showCompleted || !isDone(r.event.id))
|
.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,
|
allRows,
|
||||||
prefs.hiddenGames,
|
prefs.hiddenGames,
|
||||||
prefs.showCompleted,
|
prefs.showCompleted,
|
||||||
prefs.showIgnored,
|
prefs.showIgnored,
|
||||||
|
prefs.sort,
|
||||||
prog.progress,
|
prog.progress,
|
||||||
daily.logs,
|
daily.logs,
|
||||||
ignored.marks,
|
ignored.marks,
|
||||||
@@ -254,6 +272,14 @@ export function App() {
|
|||||||
)}`
|
)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
action={
|
||||||
|
visible.length > 1 ? (
|
||||||
|
<SortControl
|
||||||
|
value={prefs.sort}
|
||||||
|
onChange={(sort) => update({ sort })}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{live.map((row) => (
|
{live.map((row) => (
|
||||||
<EventRow
|
<EventRow
|
||||||
@@ -272,7 +298,19 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{upcoming.length > 0 && (
|
{upcoming.length > 0 && (
|
||||||
<Section title="Not started yet">
|
<Section
|
||||||
|
title="Not started yet"
|
||||||
|
// The ordering control lives with the first list on the page, so
|
||||||
|
// it is never missing when there is something to order.
|
||||||
|
action={
|
||||||
|
live.length === 0 && upcoming.length > 1 ? (
|
||||||
|
<SortControl
|
||||||
|
value={prefs.sort}
|
||||||
|
onChange={(sort) => update({ sort })}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{upcoming.map((row) => (
|
{upcoming.map((row) => (
|
||||||
<EventRow
|
<EventRow
|
||||||
key={row.event.id}
|
key={row.event.id}
|
||||||
@@ -374,18 +412,21 @@ function Section({
|
|||||||
title,
|
title,
|
||||||
hint,
|
hint,
|
||||||
legend,
|
legend,
|
||||||
|
action,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
hint?: string | undefined;
|
hint?: string | undefined;
|
||||||
legend?: boolean | undefined;
|
legend?: boolean | undefined;
|
||||||
|
/** A control that belongs to this section, e.g. how it is ordered. */
|
||||||
|
action?: React.ReactNode | undefined;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="pt-5">
|
<section className="pt-5">
|
||||||
<div className="flex items-baseline justify-between gap-3 px-4 pb-2">
|
<div className="flex items-baseline justify-between gap-3 px-4 pb-2">
|
||||||
<h2 className="eyebrow">{title}</h2>
|
<h2 className="eyebrow">{title}</h2>
|
||||||
{hint !== undefined && <p className="text-xs text-faint">{hint}</p>}
|
{action ?? (hint !== undefined && <p className="text-xs text-faint">{hint}</p>)}
|
||||||
</div>
|
</div>
|
||||||
{legend === true && <Legend />}
|
{legend === true && <Legend />}
|
||||||
<ul className="border-t border-hairline">{children}</ul>
|
<ul className="border-t border-hairline">{children}</ul>
|
||||||
@@ -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 (
|
||||||
|
<div role="group" aria-label="Sort events" className="flex gap-1">
|
||||||
|
{SORT_MODES.map((mode) => {
|
||||||
|
const on = value === mode.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(mode.id)}
|
||||||
|
aria-pressed={on}
|
||||||
|
title={mode.hint}
|
||||||
|
className={`rounded-full border px-2.5 py-1 text-[0.6875rem] font-medium transition-colors ${
|
||||||
|
on
|
||||||
|
? "border-ink/60 text-ink"
|
||||||
|
: "border-transparent text-faint hover:text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function exportProgress(
|
function exportProgress(
|
||||||
progress: Record<string, unknown>,
|
progress: Record<string, unknown>,
|
||||||
daily: DailyLogMap,
|
daily: DailyLogMap,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { GameId, Region } from "../../shared/schema.ts";
|
import type { GameId, Region } from "../../shared/schema.ts";
|
||||||
import { guessRegion } from "../../shared/time.ts";
|
import { guessRegion } from "../../shared/time.ts";
|
||||||
|
import type { SortMode } from "./sort.ts";
|
||||||
import { KEYS, readJson, writeJson } from "./storage.ts";
|
import { KEYS, readJson, writeJson } from "./storage.ts";
|
||||||
|
|
||||||
export interface Prefs {
|
export interface Prefs {
|
||||||
region: Region;
|
region: Region;
|
||||||
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
|
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
|
||||||
hiddenGames: GameId[];
|
hiddenGames: GameId[];
|
||||||
|
/** How the list is ordered. Deadline order is the default and the fallback. */
|
||||||
|
sort: SortMode;
|
||||||
showCompleted: boolean;
|
showCompleted: boolean;
|
||||||
/** Reveal events the reader has ignored, so they can be restored. */
|
/** Reveal events the reader has ignored, so they can be restored. */
|
||||||
showIgnored: boolean;
|
showIgnored: boolean;
|
||||||
@@ -20,6 +23,7 @@ function defaults(): Prefs {
|
|||||||
return {
|
return {
|
||||||
region: guessRegion(),
|
region: guessRegion(),
|
||||||
hiddenGames: [],
|
hiddenGames: [],
|
||||||
|
sort: "ending",
|
||||||
showCompleted: true,
|
showCompleted: true,
|
||||||
showIgnored: false,
|
showIgnored: false,
|
||||||
regionConfirmed: false,
|
regionConfirmed: false,
|
||||||
|
|||||||
@@ -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<string, Activity>) => (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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user