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
+26 -1
View File
@@ -76,15 +76,17 @@ re-verify a sample against the live page afterward.
```
src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.ts, feed.ts
custom.ts — reader-authored games and events, and their key spaces
src/ingest/ html.ts, dates.ts (nine formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts
parsers/ game8.ts, wikigg.ts, akwiki.ts — keyed by SITE, not game
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
src/client/ React app, service worker, manifest
state/ progress, daily log, ignores, prefs, sort — all localStorage
useCustom.ts — the reader's own games and events (PRD F13)
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health
test/ 366 tests
test/ 396 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README
```
@@ -229,6 +231,29 @@ event's length. It adds **no schema field**, so the feed contract is untouched.
recording agreement would freeze today's guess and stop a better parser from ever reaching that
event. Neither control ever deletes a mark or a logged day, so both are reversible.
## Events the reader entered themselves
No adapter list covers a ten-game player, so a reader can define a game and type in events
(PRD F13, `src/shared/custom.ts`, `src/client/state/useCustom.ts`). They join the same lists,
timeline, sort, filters, progress, ignore and daily stores as scraped events. Four rules:
- **Their ids live in their own spaces**: `mygame:<slug>` and `myevent:<random>`. Never
`${game}:${slug}:${date}` — a reader can type a scraped event's exact title and date, and that
collision would silently share one completion mark and one streak between two events. Random also
means renaming their own event never moves its id. `dailies`, `mygame` and `myevent` are reserved
first segments and **none may ever become a `GameId`**; a test pins this.
- **Nothing they type enters the ingest pipeline.** `sanitize.ts` and `merge.ts` are for pages we do
not control. Their events are not fetched, parsed, merged, scored or quarantined.
- **A hand-entered date is never attributed to a source.** No `sourceUrl`, no source link, and the
row and detail sheet both say it is theirs. `"I don't know when it ends"` is an offered answer, for
the same reason the parsers are forbidden from guessing one.
- **They are in the export.** These exist in one browser and nowhere else, so an export without them
is a lossy backup. Import merges by id and never removes.
A lane may now be a game the reader invented, so `gameMeta` is a context resolver (`metaFor`, pure
and total) rather than a direct lookup — a lane can outlive its game when an import carries an event
whose game did not come with it.
## Conventions
- **Zod schemas are the single source of truth for types.** Derive with `z.infer<>`; never
+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>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { EventForm } from "../src/client/components/CustomForms.tsx";
import { YourOwn } from "../src/client/components/YourOwn.tsx";
import { EventRow } from "../src/client/components/EventRow.tsx";
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
import {
asDisplayEvent,
CustomEvent,
type CustomEvents,
type CustomGames,
} from "../src/shared/custom.ts";
import { metaFor } from "../src/shared/games.ts";
import { clockFor } from "../src/shared/time.ts";
/**
* Static-render checks on the F13 surfaces.
*
* Not a substitute for using the thing, but they pin the two claims the feature
* makes to a reader: their event is marked as theirs, and the end date is
* allowed to be unknown.
*/
const AT = "2026-08-17T12:00:00.000Z";
const GAMES: CustomGames = {
"mygame:limbus-company": {
id: "mygame:limbus-company",
name: "Limbus Company",
hue: "#C74B50",
at: AT,
},
};
const OWN = CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Walpurgisnacht",
type: "banner",
summary: null,
startsAt: "2026-08-20T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-03T00:00:00.000Z",
endPrecision: "day",
at: AT,
updatedAt: AT,
});
const EVENTS: CustomEvents = { [OWN.id]: OWN };
function render(node: React.ReactElement): string {
return renderToStaticMarkup(
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>{node}</GameMetaProvider>,
);
}
describe("YourOwn", () => {
const noop = () => {};
const props = {
games: GAMES,
events: EVENTS,
lanes: ["genshin", "mygame:limbus-company"],
onAddGame: noop,
onEditGame: noop,
onRemoveGame: () => ({ removed: true, blockedBy: 0 }),
onAddEvent: noop,
};
test("lists a reader's game with the events it holds", () => {
const html = render(<YourOwn {...props} />);
expect(html).toContain("Limbus Company");
expect(html).toContain("1 event");
expect(html).toContain("#C74B50");
});
test("shows a game with nothing in it rather than hiding it", () => {
// A game they just made has to appear before it holds anything, or adding
// one looks like it did nothing.
const html = render(<YourOwn {...props} events={{}} />);
expect(html).toContain("no events yet");
});
});
describe("EventForm", () => {
test("offers an unknown end rather than demanding a date", () => {
// The whole point: a form that made the end mandatory would push the reader
// into inventing one, which is the failure the parsers are forbidden from.
const html = render(
<EventForm
lanes={["genshin", "mygame:limbus-company"]}
customGames={GAMES}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("I don&#x27;t know when it ends");
});
test("lets an event be filed under a tracked game too", () => {
// A source can miss an event in a game we do cover.
const html = render(
<EventForm
lanes={["genshin", "mygame:limbus-company"]}
customGames={GAMES}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("Genshin Impact");
expect(html).toContain("Limbus Company (yours)");
});
});
describe("EventRow provenance", () => {
const row = (id: string) => {
const event = { ...asDisplayEvent(OWN), id };
return { event, clock: clockFor(event, "europe", Date.parse(AT)) };
};
test("marks the reader's own event as theirs", () => {
const html = render(
<ul>
<EventRow row={row(OWN.id)} completed={false} onOpen={() => {}} />
</ul>,
);
expect(html).toContain("yours");
});
test("does not mark a scraped event as theirs", () => {
const html = render(
<ul>
<EventRow
row={row("genshin:windblume-festival:2026-03-14")}
completed={false}
onOpen={() => {}}
/>
</ul>,
);
expect(html).not.toContain(">yours<");
});
});