feat(custom): let readers add their own games and events

The interface for PRD F13. A game (name and lane colour) and events against it,
or against a tracked game when a source missed one, managed from the settings
panel and from the event's own detail sheet.

Two things the forms are careful about:

- "I don't know when it ends" is an offered answer, not a blank field. A
  mandatory end date would push the reader into inventing one, which is the
  failure the parsers are forbidden from committing — it would just be the
  reader committing it instead. An unknown end behaves as it does for a scraped
  event: no countdown, no checklist.
- A hand-entered date is never dressed up as a source's. The row carries a
  "yours" chip, the sheet says so under the title, and the Source link is absent
  rather than dead.

Deleting a game is refused while it still holds events, and says how many.
Deleting an event leaves the marks and ticks attached to it alone.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 18:24:09 +02:00
co-authored by Claude Opus 5
parent d046758671
commit c87ea4c602
8 changed files with 786 additions and 5 deletions
+22
View File
@@ -463,6 +463,17 @@ export function App() {
onToggleGame={toggleGame}
onUpdate={update}
ignoredCount={Object.keys(ignored.marks).length}
own={{
games: custom.games,
events: custom.events,
// Every lane, not just theirs: a source can miss an event in a game
// we do track, and that is the same job with the same form.
lanes: games,
onAddGame: custom.addGame,
onEditGame: custom.editGame,
onRemoveGame: custom.removeGame,
onAddEvent: custom.addEvent,
}}
onExport={() =>
exportProgress(prog.progress, daily.logs, ignored.marks, prefs, {
games: custom.games,
@@ -524,6 +535,17 @@ export function App() {
onNote={prog.setNote}
onIgnore={(id) => toggleIgnored(id, openRow.event.title)}
onClose={() => setOpenId(null)}
own={
custom.events[openRow.event.id] === undefined
? undefined
: {
record: custom.events[openRow.event.id]!,
lanes: games,
games: custom.games,
onSave: custom.editEvent,
onDelete: custom.removeEvent,
}
}
/>
)}
</Shell>
+8 -2
View File
@@ -2,6 +2,7 @@ import type { LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { Prefs } from "../state/usePrefs.ts";
import { YourOwn } from "./YourOwn.tsx";
const REGIONS: Array<{ id: Region; label: string }> = [
{ id: "america", label: "America" },
@@ -17,6 +18,7 @@ export function Controls({
ignoredCount,
onExport,
onImport,
own,
}: {
games: LaneId[];
prefs: Prefs;
@@ -25,6 +27,8 @@ export function Controls({
ignoredCount: number;
onExport: () => void;
onImport: (file: File) => void;
/** Everything the reader entered themselves, and the ways to change it. */
own: React.ComponentProps<typeof YourOwn>;
}) {
const gameMeta = useGameMeta();
return (
@@ -127,12 +131,14 @@ export function Controls({
</div>
</div>
<YourOwn {...own} />
<div className="mt-6 border-t border-hairline pt-4">
<p className="eyebrow">Your progress</p>
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
What you've finished, and every daily you've ticked off, are saved in
this browser only there is no account. Move them to another device
with a file.
this browser only there is no account. Anything you added yourself is
in there too. Move it all to another device with a file.
</p>
<div className="mt-3 flex gap-2">
<button
+343
View File
@@ -0,0 +1,343 @@
import { useState } from "react";
import {
isCustomGameId,
type CustomEvent,
type CustomGames,
type LaneId,
} from "../../shared/custom.ts";
import { EventType } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import { readerInstant, type EventDraft } from "../state/useCustom.ts";
/**
* Entering a game and an event yourself (PRD F13).
*
* The forms deliberately mirror what a parser is allowed to produce rather than
* what a database column will accept — most of all, **"I don't know" is a
* first-class answer for the end date.** A form that made the end mandatory
* would force a reader to invent one, which is the single failure this whole
* product is built to avoid; it just happens to be the reader inventing it
* instead of us.
*/
/** Enough hues to tell lanes apart, none of them colliding with a tracked game. */
const HUES = [
"#C74B50",
"#E08A3C",
"#D9C34A",
"#5FBF6A",
"#4FB3C4",
"#5C7CE0",
"#9B6FD1",
"#D46FA8",
];
const TYPES = EventType.options;
function labelClass(): string {
return "block text-xs font-medium text-muted";
}
function inputClass(): string {
return "mt-1 w-full rounded-lg border border-hairline bg-ground px-3 py-2 text-sm text-ink outline-none focus:border-faint";
}
export function GameForm({
initial,
onSave,
onCancel,
}: {
initial?: { name: string; hue: string } | undefined;
onSave: (name: string, hue: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [hue, setHue] = useState(initial?.hue ?? HUES[0]!);
const valid = name.trim().length > 0 && name.trim().length <= 40;
return (
<form
className="mt-3 rounded-xl border border-hairline p-3"
onSubmit={(e) => {
e.preventDefault();
if (valid) onSave(name, hue);
}}
>
<label className={labelClass()}>
Game name
<input
autoFocus
value={name}
maxLength={40}
onChange={(e) => setName(e.target.value)}
placeholder="Limbus Company"
className={inputClass()}
/>
</label>
<p className={`${labelClass()} mt-3`}>Lane colour</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{HUES.map((h) => (
<button
key={h}
type="button"
aria-label={`Use colour ${h}`}
aria-pressed={hue === h}
onClick={() => setHue(h)}
className={`size-7 rounded-full border-2 transition-transform ${
hue === h ? "scale-110 border-ink" : "border-transparent"
}`}
style={{ background: h }}
/>
))}
</div>
<div className="mt-4 flex gap-2">
<button
type="submit"
disabled={!valid}
className="rounded-lg border border-transparent bg-ink px-3 py-1.5 text-xs font-medium text-ground transition-colors disabled:opacity-40"
>
Save game
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Cancel
</button>
</div>
</form>
);
}
/** Split a stored instant back into the date and time a form field wants. */
function fields(iso: string | null): { date: string; time: string } {
if (iso === null) return { date: "", time: "" };
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return {
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
};
}
export function EventForm({
lanes,
customGames,
initial,
onSave,
onCancel,
}: {
/** Every lane an event can belong to — a source can miss an event too. */
lanes: LaneId[];
customGames: CustomGames;
initial?: CustomEvent | undefined;
onSave: (draft: EventDraft) => void;
onCancel: () => void;
}) {
const gameMeta = useGameMeta();
const start = fields(initial?.startsAt ?? null);
const end = fields(initial?.endsAt ?? null);
const [game, setGame] = useState<LaneId>(
initial?.game ?? lanes[0] ?? Object.keys(customGames)[0] ?? "",
);
const [title, setTitle] = useState(initial?.title ?? "");
const [type, setType] = useState<EventType>(initial?.type ?? "other");
const [summary, setSummary] = useState(initial?.summary ?? "");
const [startDate, setStartDate] = useState(start.date);
const [startTime, setStartTime] = useState(
initial?.startPrecision === "exact" ? start.time : "",
);
// Separate from an empty end date so "I don't know" is a thing the reader
// states, not a field they leave blank and hope about.
const [endKnown, setEndKnown] = useState(initial ? initial.endsAt !== null : true);
const [endDate, setEndDate] = useState(end.date);
const [endTime, setEndTime] = useState(
initial?.endPrecision === "exact" ? end.time : "",
);
const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start");
const endsAt =
!endKnown || endDate === "" ? null : readerInstant(endDate, endTime, "end");
const endMissing = endKnown && endDate !== "" && endsAt === null;
const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt;
const valid =
title.trim().length > 0 &&
game !== "" &&
startsAt !== null &&
!backwards &&
!endMissing &&
(!endKnown || endDate !== "");
return (
<form
className="mt-3 rounded-xl border border-hairline p-3"
onSubmit={(e) => {
e.preventDefault();
if (!valid || startsAt === null) return;
onSave({
game,
title,
type,
summary,
startsAt,
startHasTime: startTime !== "",
endsAt,
endHasTime: endTime !== "",
});
}}
>
<label className={labelClass()}>
Game
<select
value={game}
onChange={(e) => setGame(e.target.value)}
className={inputClass()}
>
{lanes.map((id) => (
<option key={id} value={id}>
{gameMeta(id).name}
{isCustomGameId(id) ? " (yours)" : ""}
</option>
))}
</select>
</label>
<label className={`${labelClass()} mt-3`}>
What is it
<input
value={title}
maxLength={200}
onChange={(e) => setTitle(e.target.value)}
placeholder="Walpurgisnacht"
className={inputClass()}
/>
</label>
<label className={`${labelClass()} mt-3`}>
Kind
<select
value={type}
onChange={(e) => setType(e.target.value as EventType)}
className={inputClass()}
>
{TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<div className="mt-3 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Starts
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className={inputClass()}
/>
</label>
<label className={labelClass()}>
Time (optional)
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className={inputClass()}
/>
</label>
</div>
{/* The end is allowed to be unknown, and says so out loud. Making it
mandatory would push the reader into inventing a date, which is
exactly the failure the parsers are forbidden from committing. */}
<label className="mt-3 flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!endKnown}
onChange={(e) => setEndKnown(!e.target.checked)}
className="size-4 accent-[var(--color-near)]"
/>
I don't know when it ends
</label>
{endKnown && (
<div className="mt-2 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Ends
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className={inputClass()}
/>
</label>
<label className={labelClass()}>
Time (optional)
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className={inputClass()}
/>
</label>
</div>
)}
<label className={`${labelClass()} mt-3`}>
Note (optional)
<input
value={summary}
maxLength={500}
onChange={(e) => setSummary(e.target.value)}
placeholder="What you want to remember about it"
className={inputClass()}
/>
</label>
{!endKnown && (
<p className="mt-2 text-xs leading-relaxed text-faint">
It'll show with no countdown and no daily checklist, the same as an
event whose source hasn't announced an end.
</p>
)}
{backwards && (
<p className="mt-2 text-xs text-critical">
That ends before it starts.
</p>
)}
{endMissing && (
<p className="mt-2 text-xs text-critical">That end date isn't a real date.</p>
)}
{startTime === "" && startDate !== "" && (
<p className="mt-2 text-xs leading-relaxed text-faint">
No time given, so this counts from the start of the day where you are
and to the end of the day it finishes on.
</p>
)}
<div className="mt-4 flex gap-2">
<button
type="submit"
disabled={!valid}
className="rounded-lg border border-transparent bg-ink px-3 py-1.5 text-xs font-medium text-ground transition-colors disabled:opacity-40"
>
{initial === undefined ? "Add event" : "Save changes"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Cancel
</button>
</div>
</form>
);
}
+76 -1
View File
@@ -1,5 +1,13 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useGameMeta } from "../state/gameMeta.tsx";
import {
isCustomEventId,
type CustomEvent,
type CustomGames,
type LaneId,
} from "../../shared/custom.ts";
import type { EventDraft } from "../state/useCustom.ts";
import { EventForm } from "./CustomForms.tsx";
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
@@ -29,6 +37,7 @@ export function EventDetail({
onEffort,
onNote,
onClose,
own,
}: {
row: RowEvent;
completed: boolean;
@@ -51,8 +60,23 @@ export function EventDetail({
onEffort: (id: string, e: Effort | undefined) => void;
onNote: (id: string, n: string) => void;
onClose: () => void;
/**
* Present only when this is an event the reader entered themselves, in which
* case they can change it or take it back — this sheet is where anyone would
* look for that, rather than a list in settings.
*/
own?:
| {
record: CustomEvent;
lanes: LaneId[];
games: CustomGames;
onSave: (id: string, draft: EventDraft) => void;
onDelete: (id: string) => void;
}
| undefined;
}) {
const gameMeta = useGameMeta();
const [editing, setEditing] = useState(false);
const { event, clock } = row;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
@@ -86,6 +110,14 @@ export function EventDetail({
<h2 className="mt-1.5 font-display text-xl font-semibold leading-snug">
{event.title}
</h2>
{/* Stated plainly, next to the title rather than buried at the bottom:
a reader has to be able to tell which dates the app went and found
and which ones they typed in themselves. */}
{isCustomEventId(event.id) && (
<p className="mt-1 text-xs text-faint">
You added this. The dates are yours, not a source's.
</p>
)}
{event.summary !== null && (
<p className="mt-2 text-sm leading-relaxed text-muted">{event.summary}</p>
)}
@@ -190,6 +222,49 @@ export function EventDetail({
onNote={(n) => onNote(event.id, n)}
/>
{own !== undefined && (
<div className="mt-4 border-t border-hairline pt-4">
{editing ? (
<EventForm
lanes={own.lanes}
customGames={own.games}
initial={own.record}
onSave={(draft) => {
own.onSave(event.id, draft);
setEditing(false);
}}
onCancel={() => setEditing(false)}
/>
) : (
<div className="flex gap-2">
<button
type="button"
onClick={() => setEditing(true)}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Edit this event
</button>
<button
type="button"
onClick={() => {
own.onDelete(event.id);
onClose();
}}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-critical"
>
Delete
</button>
</div>
)}
{/* Their marks and logged days are not swept up with it: reaching
into three other stores on one tap is how a misclick costs
somebody a streak. */}
<p className="mt-2 text-xs leading-relaxed text-faint">
Deleting removes the event. Anything you ticked off stays.
</p>
</div>
)}
{event.endPrecision === "day" && event.endsAt !== null && (
<p className="mt-3 text-xs leading-relaxed text-faint">
The source gave a date but no time of day, so this end is accurate to
+8 -1
View File
@@ -1,4 +1,4 @@
import type { DisplayEvent } from "../../shared/custom.ts";
import { isCustomEventId, type DisplayEvent } from "../../shared/custom.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import {
formatRemaining,
@@ -88,6 +88,13 @@ export function EventRow({
<div className="min-w-0">
<span className="eyebrow flex items-center gap-1.5 truncate">
<span style={{ color: game.hue }}>{game.short}</span>
{/* A date the reader typed is never allowed to look like one
a source published. */}
{isCustomEventId(event.id) && (
<span className="rounded-[3px] border border-hairline px-1 py-px text-[0.5625rem] tracking-normal text-faint">
yours
</span>
)}
{ignored && (
<span className="rounded-[3px] bg-hairline px-1 py-px text-[0.5625rem] tracking-normal text-muted">
ignored
+162
View File
@@ -0,0 +1,162 @@
import { useState } from "react";
import type { CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { EventDraft } from "../state/useCustom.ts";
import { EventForm, GameForm } from "./CustomForms.tsx";
/**
* The reader's own games and events, in 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.
*/
export function YourOwn({
games,
events,
lanes,
onAddGame,
onEditGame,
onRemoveGame,
onAddEvent,
}: {
games: CustomGames;
events: CustomEvents;
/** Every lane an event may be filed under, tracked games included. */
lanes: LaneId[];
onAddGame: (name: string, hue: string) => void;
onEditGame: (id: string, name: string, hue: string) => void;
onRemoveGame: (id: string) => { removed: boolean; blockedBy: number };
onAddEvent: (draft: EventDraft) => 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);
return (
<div className="mt-6 border-t border-hairline pt-4">
<p className="eyebrow">Your own games and events</p>
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
Track something this app doesn't cover, or an event a source missed. Your
dates are yours — they're never presented as coming from a wiki, and they
travel in your export.
</p>
{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;
return (
<li key={game.id}>
<div className="flex items-center gap-2">
<span
aria-hidden
className="size-2.5 shrink-0 rounded-full"
style={{ background: game.hue }}
/>
<span className="min-w-0 flex-1 truncate text-sm">{game.name}</span>
<span className="shrink-0 text-xs text-faint">
{held === 0
? "no events yet"
: `${held} event${held > 1 ? "s" : ""}`}
</span>
<button
type="button"
onClick={() => {
setEditing(editing === game.id ? null : game.id);
setRefusal(null);
}}
className="shrink-0 text-xs text-faint transition-colors hover:text-ink"
>
Edit
</button>
<button
type="button"
onClick={() => {
const result = onRemoveGame(game.id);
// Refused rather than cascading: deleting a lane should
// not quietly take a fortnight of events with it.
setRefusal(
result.removed
? null
: `${game.name} still has ${result.blockedBy} event${
result.blockedBy > 1 ? "s" : ""
}. Delete those first.`,
);
}}
className="shrink-0 text-xs text-faint transition-colors hover:text-critical"
>
Delete
</button>
</div>
{editing === game.id && (
<GameForm
initial={{ name: game.name, hue: game.hue }}
onSave={(name, hue) => {
onEditGame(game.id, name, hue);
setEditing(null);
}}
onCancel={() => setEditing(null)}
/>
)}
</li>
);
})}
</ul>
)}
{refusal !== null && (
<p className="mt-2 text-xs leading-relaxed text-critical">{refusal}</p>
)}
{adding === "game" && (
<GameForm
onSave={(name, hue) => {
onAddGame(name, hue);
setAdding(null);
}}
onCancel={() => setAdding(null)}
/>
)}
{adding === "event" && (
<EventForm
lanes={lanes}
customGames={games}
onSave={(draft) => {
onAddEvent(draft);
setAdding(null);
}}
onCancel={() => setAdding(null)}
/>
)}
{adding === null && (
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => setAdding("game")}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Add a game
</button>
<button
type="button"
onClick={() => setAdding("event")}
disabled={lanes.length === 0}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink disabled:opacity-40"
>
Add an event
</button>
</div>
)}
</div>
);
}