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:
co-authored by
Claude Opus 5
parent
d046758671
commit
c87ea4c602
@@ -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
|
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
|
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
|
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
|
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
||||||
src/client/ React app, service worker, manifest
|
src/client/ React app, service worker, manifest
|
||||||
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
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
|
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
||||||
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 366 tests
|
test/ 396 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
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
|
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.
|
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
|
## Conventions
|
||||||
|
|
||||||
- **Zod schemas are the single source of truth for types.** Derive with `z.infer<>`; never
|
- **Zod schemas are the single source of truth for types.** Derive with `z.infer<>`; never
|
||||||
|
|||||||
@@ -463,6 +463,17 @@ export function App() {
|
|||||||
onToggleGame={toggleGame}
|
onToggleGame={toggleGame}
|
||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
ignoredCount={Object.keys(ignored.marks).length}
|
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={() =>
|
onExport={() =>
|
||||||
exportProgress(prog.progress, daily.logs, ignored.marks, prefs, {
|
exportProgress(prog.progress, daily.logs, ignored.marks, prefs, {
|
||||||
games: custom.games,
|
games: custom.games,
|
||||||
@@ -524,6 +535,17 @@ export function App() {
|
|||||||
onNote={prog.setNote}
|
onNote={prog.setNote}
|
||||||
onIgnore={(id) => toggleIgnored(id, openRow.event.title)}
|
onIgnore={(id) => toggleIgnored(id, openRow.event.title)}
|
||||||
onClose={() => setOpenId(null)}
|
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>
|
</Shell>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LaneId } from "../../shared/custom.ts";
|
|||||||
import type { Region } from "../../shared/schema.ts";
|
import type { Region } from "../../shared/schema.ts";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
import type { Prefs } from "../state/usePrefs.ts";
|
import type { Prefs } from "../state/usePrefs.ts";
|
||||||
|
import { YourOwn } from "./YourOwn.tsx";
|
||||||
|
|
||||||
const REGIONS: Array<{ id: Region; label: string }> = [
|
const REGIONS: Array<{ id: Region; label: string }> = [
|
||||||
{ id: "america", label: "America" },
|
{ id: "america", label: "America" },
|
||||||
@@ -17,6 +18,7 @@ export function Controls({
|
|||||||
ignoredCount,
|
ignoredCount,
|
||||||
onExport,
|
onExport,
|
||||||
onImport,
|
onImport,
|
||||||
|
own,
|
||||||
}: {
|
}: {
|
||||||
games: LaneId[];
|
games: LaneId[];
|
||||||
prefs: Prefs;
|
prefs: Prefs;
|
||||||
@@ -25,6 +27,8 @@ export function Controls({
|
|||||||
ignoredCount: number;
|
ignoredCount: number;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
onImport: (file: File) => void;
|
onImport: (file: File) => void;
|
||||||
|
/** Everything the reader entered themselves, and the ways to change it. */
|
||||||
|
own: React.ComponentProps<typeof YourOwn>;
|
||||||
}) {
|
}) {
|
||||||
const gameMeta = useGameMeta();
|
const gameMeta = useGameMeta();
|
||||||
return (
|
return (
|
||||||
@@ -127,12 +131,14 @@ export function Controls({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<YourOwn {...own} />
|
||||||
|
|
||||||
<div className="mt-6 border-t border-hairline pt-4">
|
<div className="mt-6 border-t border-hairline pt-4">
|
||||||
<p className="eyebrow">Your progress</p>
|
<p className="eyebrow">Your progress</p>
|
||||||
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
|
<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
|
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
|
this browser only — there is no account. Anything you added yourself is
|
||||||
with a file.
|
in there too. Move it all to another device with a file.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
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 { formatAbsolute, formatRemaining } from "../../shared/time.ts";
|
||||||
import type { RowEvent } from "./EventRow.tsx";
|
import type { RowEvent } from "./EventRow.tsx";
|
||||||
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
|
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
|
||||||
@@ -29,6 +37,7 @@ export function EventDetail({
|
|||||||
onEffort,
|
onEffort,
|
||||||
onNote,
|
onNote,
|
||||||
onClose,
|
onClose,
|
||||||
|
own,
|
||||||
}: {
|
}: {
|
||||||
row: RowEvent;
|
row: RowEvent;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
@@ -51,8 +60,23 @@ export function EventDetail({
|
|||||||
onEffort: (id: string, e: Effort | undefined) => void;
|
onEffort: (id: string, e: Effort | undefined) => void;
|
||||||
onNote: (id: string, n: string) => void;
|
onNote: (id: string, n: string) => void;
|
||||||
onClose: () => 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 gameMeta = useGameMeta();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
const { event, clock } = row;
|
const { event, clock } = row;
|
||||||
const game = gameMeta(event.game);
|
const game = gameMeta(event.game);
|
||||||
const heat = URGENCY_COLOR[clock.urgency];
|
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">
|
<h2 className="mt-1.5 font-display text-xl font-semibold leading-snug">
|
||||||
{event.title}
|
{event.title}
|
||||||
</h2>
|
</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 && (
|
{event.summary !== null && (
|
||||||
<p className="mt-2 text-sm leading-relaxed text-muted">{event.summary}</p>
|
<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)}
|
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 && (
|
{event.endPrecision === "day" && event.endsAt !== null && (
|
||||||
<p className="mt-3 text-xs leading-relaxed text-faint">
|
<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
|
The source gave a date but no time of day, so this end is accurate to
|
||||||
|
|||||||
@@ -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 { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
import {
|
import {
|
||||||
formatRemaining,
|
formatRemaining,
|
||||||
@@ -88,6 +88,13 @@ export function EventRow({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="eyebrow flex items-center gap-1.5 truncate">
|
<span className="eyebrow flex items-center gap-1.5 truncate">
|
||||||
<span style={{ color: game.hue }}>{game.short}</span>
|
<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 && (
|
{ignored && (
|
||||||
<span className="rounded-[3px] bg-hairline px-1 py-px text-[0.5625rem] tracking-normal text-muted">
|
<span className="rounded-[3px] bg-hairline px-1 py-px text-[0.5625rem] tracking-normal text-muted">
|
||||||
ignored
|
ignored
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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<");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user