feat(custom): model reader-authored games and events
The data layer for PRD F13, with no UI and no behaviour change yet.
src/shared/custom.ts defines the two key spaces, their schemas, and the
projection into what the views read. Two properties are the point of it:
- A reader's event id is random, not derived from their title. They can type a
scraped event's exact name and date, which under ${game}:${slug}:${date} is a
byte-identical key — one completion mark and one streak silently shared by two
events. Randomness also means renaming their own event never moves its id.
- A reader's event carries no sourceUrl, so a hand-entered date can never be
attributed to a source, and claims no region split, because they entered one
instant and inventing three would fabricate two of them.
The rest is widening what was GameId-shaped into a lane that may be one of
theirs: clockFor takes the boundary fields structurally so their events run on
the identical countdown rather than a second one, day keys fall back to the
regional default for a lane with no server map, and gameMeta becomes a context
resolver so metaFor stays pure and total — a lane can outlive its game when an
import carries an event whose game did not come with it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4029885833
commit
3702ee7a4c
@@ -0,0 +1,202 @@
|
||||
import { z } from "zod";
|
||||
import { EventType, GachaEvent, Precision, slugify } from "./schema.ts";
|
||||
|
||||
/**
|
||||
* Games and events the reader entered themselves (PRD F13).
|
||||
*
|
||||
* No source publishes these, nothing fetches them, and they never enter the
|
||||
* ingest pipeline — `sanitize.ts` and `merge.ts` exist for pages we do not
|
||||
* control, and a reader's own typing is neither untrusted markup nor a second
|
||||
* opinion to reconcile. What they *do* share with scraped events is everything
|
||||
* downstream: the same lists, the same clock, the same progress, ignore and
|
||||
* daily-checklist stores, keyed by the ids below.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reserved first segments. None of these may ever become a `GameId`.
|
||||
*
|
||||
* Every id in this app is colon-separated and the first segment decides which
|
||||
* space it belongs to: `dailies:<game>` is a standing chore, `mygame:` and
|
||||
* `myevent:` are the reader's own, and anything else is `<game>:<slug>:<date>`
|
||||
* from a source. The day one of these becomes a game id is the day two key
|
||||
* spaces merge silently, so a test pins it against `GameId.options`.
|
||||
*/
|
||||
export const RESERVED_ID_SEGMENTS = ["dailies", "mygame", "myevent"] as const;
|
||||
|
||||
const GAME_PREFIX = "mygame";
|
||||
const EVENT_PREFIX = "myevent";
|
||||
|
||||
export const CustomGameId = z.string().regex(/^mygame:[a-z0-9-]{1,60}$/);
|
||||
export const CustomEventId = z.string().regex(/^myevent:[a-z0-9]{6,32}$/);
|
||||
|
||||
/**
|
||||
* Anything that can key a lane: a tracked game or one the reader defined.
|
||||
*
|
||||
* Deliberately a plain string rather than a union — it is read by filters,
|
||||
* focus, counts and day keys, none of which care which kind it is, and a union
|
||||
* would push a narrowing at every one of those call sites for no safety.
|
||||
*/
|
||||
export type LaneId = string;
|
||||
|
||||
export function isCustomGameId(id: string): boolean {
|
||||
return id.startsWith(`${GAME_PREFIX}:`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event is the reader's own.
|
||||
*
|
||||
* The id is the authority, not `extractionMethod` — that says a human entered
|
||||
* the value, which is also true of an event approved through the review gate.
|
||||
* This says *this* reader entered it, which is what the UI must not get wrong:
|
||||
* their own date is never attributed to a source.
|
||||
*/
|
||||
export function isCustomEventId(id: string): boolean {
|
||||
return id.startsWith(`${EVENT_PREFIX}:`);
|
||||
}
|
||||
|
||||
export const CustomGame = z.object({
|
||||
id: CustomGameId,
|
||||
name: z.string().min(1).max(40),
|
||||
/**
|
||||
* Reaches a `style` attribute, and an imported file is not necessarily one
|
||||
* this reader wrote — so the shape is checked rather than trusted.
|
||||
*/
|
||||
hue: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
at: z.string().datetime(),
|
||||
});
|
||||
export type CustomGame = z.infer<typeof CustomGame>;
|
||||
|
||||
export const CustomEvent = z
|
||||
.object({
|
||||
id: CustomEventId,
|
||||
/** A tracked game, or one of theirs — a source can miss an event too. */
|
||||
game: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
type: EventType,
|
||||
summary: z.string().max(500).nullable(),
|
||||
|
||||
startsAt: z.string().datetime(),
|
||||
startPrecision: Precision,
|
||||
endsAt: z.string().datetime().nullable(),
|
||||
endPrecision: Precision,
|
||||
|
||||
at: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
})
|
||||
// The same invariants the feed is held to. "I don't know when this ends" has
|
||||
// to be expressible here too, or entering an unannounced event would force
|
||||
// the reader to invent a date — the one thing this product refuses to do.
|
||||
.refine((e) => (e.endsAt === null) === (e.endPrecision === "unknown"), {
|
||||
message: "endsAt null must pair with endPrecision 'unknown'",
|
||||
path: ["endPrecision"],
|
||||
})
|
||||
.refine((e) => e.endsAt === null || e.endsAt > e.startsAt, {
|
||||
message: "endsAt must be after startsAt",
|
||||
path: ["endsAt"],
|
||||
});
|
||||
export type CustomEvent = z.infer<typeof CustomEvent>;
|
||||
|
||||
export const CustomGames = z.record(z.string(), CustomGame);
|
||||
export const CustomEvents = z.record(z.string(), CustomEvent);
|
||||
export type CustomGames = z.infer<typeof CustomGames>;
|
||||
export type CustomEvents = z.infer<typeof CustomEvents>;
|
||||
|
||||
/**
|
||||
* What every view in the client actually reads.
|
||||
*
|
||||
* A feed event satisfies this as-is; a reader's event is projected into it by
|
||||
* `asDisplayEvent`. Only two fields widen, and both for the same reason — the
|
||||
* reader's events are not from a source:
|
||||
*
|
||||
* game may be a lane they invented, so not a `GameId`
|
||||
* sourceUrl is null, because there is no page to send a sceptic to
|
||||
*/
|
||||
export type DisplayEvent = Omit<GachaEvent, "game" | "sourceUrl"> & {
|
||||
game: LaneId;
|
||||
sourceUrl: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Project a reader's event into the shape the views read.
|
||||
*
|
||||
* `extractionMethod: "manual"` and `confidence: 1` are the existing vocabulary
|
||||
* for "a human asserted this", so nothing new is invented to describe it. The
|
||||
* region fields say false/null because the reader entered one instant, not a
|
||||
* per-region map — turning one timestamp into three would fabricate two of them.
|
||||
*/
|
||||
export function asDisplayEvent(event: CustomEvent): DisplayEvent {
|
||||
return {
|
||||
id: event.id,
|
||||
game: event.game,
|
||||
title: event.title,
|
||||
type: event.type,
|
||||
summary: event.summary,
|
||||
startsAt: event.startsAt,
|
||||
startPrecision: event.startPrecision,
|
||||
endsAt: event.endsAt,
|
||||
endPrecision: event.endPrecision,
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: null,
|
||||
sourceId: "you",
|
||||
status: "published",
|
||||
confidence: 1,
|
||||
extractionMethod: "manual",
|
||||
version: 1,
|
||||
firstSeenAt: event.at,
|
||||
updatedAt: event.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable id for a game the reader named, unique among the ones they have.
|
||||
*
|
||||
* Slug-derived so it reads in an export, and suffixed on collision rather than
|
||||
* overwriting — two games called "Nikke" are two games.
|
||||
*/
|
||||
export function mintCustomGameId(
|
||||
name: string,
|
||||
taken: Iterable<string> = [],
|
||||
): string {
|
||||
const base = slugify(name).slice(0, 60) || "game";
|
||||
const used = new Set(taken);
|
||||
let id = `${GAME_PREFIX}:${base}`;
|
||||
for (let n = 2; used.has(id); n += 1) id = `${GAME_PREFIX}:${base}-${n}`;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A random id for an event the reader entered.
|
||||
*
|
||||
* Random, not derived from the title, for two reasons. It cannot collide with
|
||||
* `${game}:${slug}:${date}` even when they type a scraped event's exact name and
|
||||
* date — that collision would silently share one completion mark and one streak
|
||||
* between two events. And it does not move when they rename their own event, so
|
||||
* editing a typo in a title never costs them the marks attached to it.
|
||||
*/
|
||||
export function mintCustomEventId(random: () => number = Math.random): string {
|
||||
let token = "";
|
||||
while (token.length < 10) {
|
||||
token += Math.floor(random() * 36 ** 6)
|
||||
.toString(36)
|
||||
.padStart(6, "0");
|
||||
}
|
||||
return `${EVENT_PREFIX}:${token.slice(0, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The precision a reader's boundary actually has.
|
||||
*
|
||||
* A date typed with no time of day is `"day"`, exactly as a source that printed
|
||||
* one would be — so the detail sheet's existing "accurate to the day only" note
|
||||
* is honest about their input too, rather than presenting midnight as a time
|
||||
* they chose.
|
||||
*/
|
||||
export function precisionOf(hasTime: boolean): Extract<Precision, "exact" | "day"> {
|
||||
return hasTime ? "exact" : "day";
|
||||
}
|
||||
|
||||
/** True when `id` is a game this reader defined and still has. */
|
||||
export function knownLane(id: LaneId, games: CustomGames): boolean {
|
||||
return !isCustomGameId(id) || games[id] !== undefined;
|
||||
}
|
||||
+13
-8
@@ -1,4 +1,5 @@
|
||||
import { GAMES } from "./games.ts";
|
||||
import type { LaneId } from "./custom.ts";
|
||||
import type { GachaEvent, GameId, Region } from "./schema.ts";
|
||||
import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts";
|
||||
|
||||
@@ -127,8 +128,12 @@ export function dailyOverride(
|
||||
* that *do* have their own server onto somebody else's clock, which is a
|
||||
* different bug in the same place.
|
||||
*/
|
||||
export function serverOffsetUtc(region: Region, game?: GameId): number {
|
||||
const override = game === undefined ? undefined : GAMES[game].resetOffsets?.[region];
|
||||
export function serverOffsetUtc(region: Region, game?: LaneId): number {
|
||||
// A lane the reader invented (PRD F13) has no server map to know about, and
|
||||
// neither does an id that has outlived its game, so both take the regional
|
||||
// default rather than being looked up and crashing.
|
||||
const override =
|
||||
game === undefined ? undefined : GAMES[game as GameId]?.resetOffsets?.[region];
|
||||
return override ?? REGION_RESET_UTC_OFFSET[region];
|
||||
}
|
||||
|
||||
@@ -141,7 +146,7 @@ export function serverOffsetUtc(region: Region, game?: GameId): number {
|
||||
* but it is still the reader's streak moving under them. Treat a change here as
|
||||
* a data change, not a constant.
|
||||
*/
|
||||
function shift(region: Region, game?: GameId): number {
|
||||
function shift(region: Region, game?: LaneId): number {
|
||||
return serverOffsetUtc(region, game) * HOUR - RESET_HOUR_LOCAL * HOUR;
|
||||
}
|
||||
|
||||
@@ -155,18 +160,18 @@ function shift(region: Region, game?: GameId): number {
|
||||
* generic "what day is it here?" — still gets the regional answer. Anything
|
||||
* that reads or writes a tick should pass it.
|
||||
*/
|
||||
export function dayKey(ms: number, region: Region, game?: GameId): string {
|
||||
export function dayKey(ms: number, region: Region, game?: LaneId): string {
|
||||
return new Date(ms + shift(region, game)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The next reset instant strictly after `ms`. */
|
||||
export function nextResetMs(ms: number, region: Region, game?: GameId): number {
|
||||
export function nextResetMs(ms: number, region: Region, game?: LaneId): number {
|
||||
const s = shift(region, game);
|
||||
return Math.floor((ms + s) / DAY) * DAY + DAY - s;
|
||||
}
|
||||
|
||||
/** How long the reader has left to do today's dailies. */
|
||||
export function msUntilReset(ms: number, region: Region, game?: GameId): number {
|
||||
export function msUntilReset(ms: number, region: Region, game?: LaneId): number {
|
||||
return nextResetMs(ms, region, game) - ms;
|
||||
}
|
||||
|
||||
@@ -181,7 +186,7 @@ export function dailyDays(
|
||||
startsMs: number,
|
||||
endsMs: number | null,
|
||||
region: Region,
|
||||
game?: GameId,
|
||||
game?: LaneId,
|
||||
): string[] | null {
|
||||
if (endsMs === null) return null;
|
||||
|
||||
@@ -230,7 +235,7 @@ export function dailySummary(input: {
|
||||
endsMs: number | null;
|
||||
region: Region;
|
||||
/** Whose reset clock this runs on. Omitted falls back to the region's. */
|
||||
game?: GameId | undefined;
|
||||
game?: LaneId | undefined;
|
||||
now: number;
|
||||
logged: readonly string[];
|
||||
}): DailySummary {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CustomGames, LaneId } from "./custom.ts";
|
||||
import type { GameId, Region } from "./schema.ts";
|
||||
|
||||
export interface GameMeta {
|
||||
@@ -72,3 +73,48 @@ export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||
export function gameMeta(id: GameId): GameMeta {
|
||||
return GAMES[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta for any lane, including one the reader invented (PRD F13).
|
||||
*
|
||||
* Pure, and total. Total matters: a lane id can outlive the game it names —
|
||||
* an import can carry an event whose game did not come with it, and a reader
|
||||
* can delete a game a stale render is still holding. Returning a neutral
|
||||
* placeholder keeps that a visible oddity rather than a blank screen, which is
|
||||
* the trade this codebase makes everywhere else in the client.
|
||||
*/
|
||||
export function metaFor(id: LaneId, custom: CustomGames): GameMeta {
|
||||
const tracked = GAMES[id as GameId];
|
||||
if (tracked !== undefined) return tracked;
|
||||
|
||||
const own = custom[id];
|
||||
if (own !== undefined) {
|
||||
return {
|
||||
id: own.id as GameId,
|
||||
name: own.name,
|
||||
short: shortLabel(own.name),
|
||||
hue: own.hue,
|
||||
// Not credited in the colophon and contributing no standing chore: the
|
||||
// colophon lists the sources we fetch, and this game has none. See
|
||||
// docs/DATA-MODEL.md § Reader-authored key spaces.
|
||||
studio: "",
|
||||
dailyTasks: "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: id as GameId,
|
||||
name: "Unknown game",
|
||||
short: "?",
|
||||
hue: "#9AA3B8",
|
||||
studio: "",
|
||||
dailyTasks: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** A name that still fits a narrow lane label or a chip. */
|
||||
export function shortLabel(name: string): string {
|
||||
if (name.length <= 12) return name;
|
||||
const first = name.split(/\s+/)[0] ?? name;
|
||||
return first.length <= 12 ? first : `${name.slice(0, 11)}…`;
|
||||
}
|
||||
|
||||
+19
-3
@@ -1,4 +1,5 @@
|
||||
import type { GachaEvent, Region } from "./schema.ts";
|
||||
import type { DisplayEvent } from "./custom.ts";
|
||||
import type { Region } from "./schema.ts";
|
||||
|
||||
/**
|
||||
* Time is this product's entire subject, so the vocabulary lives in one place:
|
||||
@@ -28,8 +29,23 @@ export function guessRegion(
|
||||
return "europe";
|
||||
}
|
||||
|
||||
/**
|
||||
* The boundary fields these helpers read.
|
||||
*
|
||||
* Structural rather than `GachaEvent` so a reader's own event (PRD F13) runs on
|
||||
* exactly the same clock as a scraped one — there is no second countdown
|
||||
* implementation to keep honest.
|
||||
*/
|
||||
export type EndBearing = Pick<
|
||||
DisplayEvent,
|
||||
"endsAt" | "regionScoped" | "regionEnds"
|
||||
>;
|
||||
|
||||
/** The end instant to show this user, honouring a region-scoped event. */
|
||||
export function effectiveEnd(event: GachaEvent, region: Region): string | null {
|
||||
export function effectiveEnd(
|
||||
event: EndBearing,
|
||||
region: Region,
|
||||
): string | null {
|
||||
if (event.endsAt === null) return null;
|
||||
if (!event.regionScoped || event.regionEnds === null) return event.endsAt;
|
||||
return event.regionEnds[region] ?? event.endsAt;
|
||||
@@ -96,7 +112,7 @@ export interface EventClock {
|
||||
}
|
||||
|
||||
export function clockFor(
|
||||
event: GachaEvent,
|
||||
event: EndBearing & Pick<DisplayEvent, "startsAt">,
|
||||
region: Region,
|
||||
now: number,
|
||||
): EventClock {
|
||||
|
||||
Reference in New Issue
Block a user