Give a reader their own events back

An event of theirs that had ended was on no surface at all. Every list and
the board drop a row once its end has passed, and settings only counted the
events a game held rather than naming them — so a one-off became unreachable
the day it finished: impossible to edit, and impossible to delete out of a
store nothing else can see. Repeating events escaped only because their
occurrences roll forward.

So settings names them now, under the game they were filed against, each row
opening the same detail sheet a row on the front page does. Nothing about
how they are managed changes; what was missing was the way back to them.

Two things the index has to get right or it leaves the same hole it closes.
The lists hold occurrences, never rules, so a repeating rule's own id opens
nothing — nearestOccurrence bridges that, and answers for a finished series
too by falling back to the first occurrence, since a rule whose `until` has
passed would otherwise be exactly as stuck. And an event filed under a game
we track has no row of theirs to nest under; the form allows that, so those
get their own heading rather than trailing the list and reading as though
they belonged to whichever game came last.

Each row says why it is not on the front page — ended and when, or its
cadence — because a list of bare titles leaves you guessing which of two
entries is the dead one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:14:47 +02:00
co-authored by Claude Opus 5
parent 8671a309b0
commit 3a3b7d9c2f
8 changed files with 335 additions and 12 deletions
+11 -1
View File
@@ -30,7 +30,7 @@ import {
} from "./state/lens.ts";
import { clockFor, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
import { occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts";
import { nearestOccurrence, occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
import { metaOnTheme, useTheme } from "./state/theme.ts";
@@ -650,6 +650,16 @@ export function App() {
onEditGame: custom.editGame,
onRemoveGame: custom.removeGame,
onAddEvent: custom.addEvent,
now,
// The index lists rules; the sheet opens rows. A repeating rule's
// own id is never a row — the lists hold its occurrences — so it is
// resolved to whichever occurrence is nearest before opening.
onOpen: (id) => {
const record = custom.events[id];
const occurrence =
record === undefined ? null : nearestOccurrence(record, now);
setOpenId(occurrence === null ? id : occurrence.id);
},
}}
onExport={() =>
exportProgress(prog.progress, daily.logs, ignored.marks, prefs, {
+110 -9
View File
@@ -1,16 +1,47 @@
import { useState } from "react";
import type { CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts";
import type { CustomEvent, CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts";
import { formatAbsolute } from "../../shared/time.ts";
import type { EventDraft } from "../state/useCustom.ts";
import { EventForm, GameForm } from "./CustomForms.tsx";
import { useGameMeta } from "../state/gameMeta.tsx";
import { cadenceLabel, EventForm, GameForm } from "./CustomForms.tsx";
/**
* The reader's own games and events — a group of the settings panel (PRD F13).
*
* Their events are managed from the event itself — open it and the detail sheet
* offers edit and delete, exactly where you would look for them. What has no
* other home is the list of games they invented, and the way in to adding the
* first event, so both live here.
* Their events are still *managed* from the event itself — open one and the
* detail sheet offers edit and delete, exactly where you would look for them.
* What lives here is the way back **to** it, which the rest of the app cannot
* offer: every list and the board drop an event once it has ended, so a
* one-off of the reader's own became unreachable the day it finished —
* impossible to edit, and impossible to delete out of a store nothing else can
* reach. This index is the only surface that shows an event whatever state it
* is in.
*/
/**
* What a row says about itself, beyond its title.
*
* The job is to explain why an event is not on any other surface, because a
* list of bare titles leaves the reader guessing which of two entries is the
* dead one. A repeating event says how often instead of when: its dates roll
* forward, so printing one would disagree with the row they would find if they
* went looking.
*/
export function eventCaption(event: CustomEvent, nowMs: number): string {
const cadence = cadenceLabel(event.repeat);
if (cadence !== null) return cadence;
if (event.endsAt !== null && Date.parse(event.endsAt) < nowMs) {
return `ended ${formatAbsolute(Date.parse(event.endsAt), false)}`;
}
if (Date.parse(event.startsAt) > nowMs) {
return `starts ${formatAbsolute(Date.parse(event.startsAt), false)}`;
}
return event.endsAt === null
? "no end date"
: `until ${formatAbsolute(Date.parse(event.endsAt), false)}`;
}
export function YourOwn({
games,
events,
@@ -19,6 +50,8 @@ export function YourOwn({
onEditGame,
onRemoveGame,
onAddEvent,
now,
onOpen,
}: {
games: CustomGames;
events: CustomEvents;
@@ -28,12 +61,27 @@ export function YourOwn({
onEditGame: (id: string, name: string, hue: string) => void;
onRemoveGame: (id: string) => { removed: boolean; blockedBy: number };
onAddEvent: (draft: EventDraft) => void;
now: number;
/**
* Open one of their events. Takes the stored id — a rule's, not an
* occurrence's — and the caller resolves it to whichever row the sheet can
* actually show.
*/
onOpen: (id: string) => void;
}) {
const gameMeta = useGameMeta();
const [adding, setAdding] = useState<"game" | "event" | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [refusal, setRefusal] = useState<string | null>(null);
const list = Object.values(games);
// An event may be filed under a game we track — a source can miss one — and
// those have no row above to nest under. Listing them separately is what
// keeps this index complete: an ended event under Genshin is on no other
// surface either, and would be just as stuck.
const underTracked = Object.values(events).filter(
(e) => games[e.game] === undefined,
);
return (
// No heading or rule of its own: this is the body of a settings group that
@@ -48,9 +96,8 @@ export function YourOwn({
{list.length > 0 && (
<ul className="mt-3 flex flex-col gap-1.5">
{list.map((game) => {
const held = Object.values(events).filter(
(e) => e.game === game.id,
).length;
const mine = Object.values(events).filter((e) => e.game === game.id);
const held = mine.length;
return (
<li key={game.id}>
<div className="flex items-center gap-2">
@@ -95,6 +142,31 @@ export function YourOwn({
</button>
</div>
{/* Indented under its game rather than in one flat list,
because the games are already the structure here and a
reader looking for an event of theirs knows which game they
filed it under. */}
{mine.length > 0 && (
<ul className="mt-1 flex flex-col gap-1 border-l border-hairline pl-3">
{mine.map((event) => (
<li key={event.id}>
<button
type="button"
onClick={() => onOpen(event.id)}
className="flex w-full items-baseline gap-2 text-left transition-colors hover:text-ink"
>
<span className="min-w-0 flex-1 truncate text-sm text-muted">
{event.title}
</span>
<span className="shrink-0 text-xs text-faint">
{eventCaption(event, now)}
</span>
</button>
</li>
))}
</ul>
)}
{editing === game.id && (
<GameForm
initial={{ name: game.name, hue: game.hue }}
@@ -111,6 +183,35 @@ export function YourOwn({
</ul>
)}
{/* Its own heading rather than trailing the list above, which read as
though these belonged to whichever game happened to be last. */}
{underTracked.length > 0 && (
<p className="mt-4 text-xs text-faint">Filed under a game we track</p>
)}
{underTracked.length > 0 && (
<ul className="mt-1.5 flex flex-col gap-1">
{underTracked.map((event) => (
<li key={event.id}>
<button
type="button"
onClick={() => onOpen(event.id)}
className="flex w-full items-baseline gap-2 text-left transition-colors hover:text-ink"
>
<span className="shrink-0 text-xs text-faint">
{gameMeta(event.game).short}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-muted">
{event.title}
</span>
<span className="shrink-0 text-xs text-faint">
{eventCaption(event, now)}
</span>
</button>
</li>
))}
</ul>
)}
{refusal !== null && (
<p className="mt-2 text-xs leading-relaxed text-critical">{refusal}</p>
)}
+32
View File
@@ -474,6 +474,38 @@ export function nextOccurrences(
return occurrencesOf(event, nowMs, horizon, count);
}
/**
* The occurrence to open when the reader asks for the rule itself.
*
* The settings index lists rules, but the detail sheet opens rows, and a
* rule's own id is never a row — the lists hold its occurrences, keyed
* `myevent:<token>#<date>`. This is the bridge between the two.
*
* Running or next where there is one. Where there is not — a series whose
* `until` has passed — it falls back to the first occurrence rather than
* giving up, and that fallback is the point rather than a nicety: a finished
* rule with no future occurrence would otherwise be exactly as unreachable as
* the ended one-off this index exists to rescue, and just as impossible to
* delete. Which time round it lands on does not matter, because the reader
* has come to edit or delete the rule, not to inspect an occurrence.
*
* Null only when nothing repeats, and the caller has a row already: a
* non-repeating event's own id is in the lists unchanged.
*/
export function nearestOccurrence(
event: RepeatingEvent,
nowMs: number,
): Occurrence | null {
if (event.repeat === null) return null;
const upcoming = nextOccurrences(event, nowMs, 1);
if (upcoming.length > 0) return upcoming[0]!;
const anchor = Date.parse(event.startsAt);
if (Number.isNaN(anchor)) return null;
return occurrencesOf(event, anchor, anchor, 1)[0] ?? null;
}
/**
* How many of a rule's ids the reader has actually recorded something
* against — what a schedule edit that re-keys ids would strand.