From e8293c7e8c7e1110fd80d90e9a776947bb995b36 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 00:10:35 +0200 Subject: [PATCH] feat: add GachaEvent schema and stable event IDs Schema-level invariants enforce that endsAt is null exactly when endPrecision is "unknown", and that regionEnds is populated exactly when regionScoped is set, so an event object cannot be constructed in a state the UI would misrender. Event IDs double as localStorage keys on the client, so slugify() and eventId() carry a warning: changing them orphans every user's completion marks with no server-side recovery. Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/schema.ts | 106 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/shared/schema.ts diff --git a/src/shared/schema.ts b/src/shared/schema.ts new file mode 100644 index 0000000..42f8406 --- /dev/null +++ b/src/shared/schema.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +export const GameId = z.enum([ + "genshin", + "hsr", + "zzz", + "wuwa", + "arknights", + "endfield", + "nte", // Neverness to Everness +]); +export type GameId = z.infer; + +export const EventType = z.enum([ + "banner", // limited character/weapon rate-up + "story", // main or side story chapter, limited-time + "rerun", // returning event + "challenge", // combat/endgame cycle (Abyss, Onslaught, Trial, ...) + "login", // login rewards / check-in + "shop", // limited shop or exchange window + "maintenance", // server downtime + "other", +]); +export type EventType = z.infer; + +export const Region = z.enum(["asia", "america", "europe"]); +export type Region = z.infer; + +/** + * How much we actually know about a boundary timestamp. + * - "exact" sourced to the minute + * - "day" date known, time-of-day not stated by the source + * - "unknown" not announced; the paired timestamp is null + */ +export const Precision = z.enum(["exact", "day", "unknown"]); +export type Precision = z.infer; + +export const GachaEvent = z + .object({ + id: z.string(), + game: GameId, + 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, + + regionScoped: z.boolean(), + regionEnds: z.record(Region, z.string().datetime()).nullable(), + + sourceUrl: z.string().url(), + sourceId: z.string(), + + status: z.enum(["published", "delisted"]), + confidence: z.number().min(0).max(1), + extractionMethod: z.enum(["parser", "manual"]), + + version: z.number().int().positive(), + firstSeenAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }) + // These invariants are cheap here and prevent a whole class of wrong-date bugs + // reaching the feed. See docs/INGESTION.md § Stage 4. + .refine((e) => (e.endsAt === null) === (e.endPrecision === "unknown"), { + message: "endsAt null must pair with endPrecision 'unknown'", + path: ["endPrecision"], + }) + .refine((e) => (e.regionEnds === null) === !e.regionScoped, { + message: "regionEnds must be populated exactly when regionScoped is true", + path: ["regionEnds"], + }) + .refine((e) => e.endsAt === null || e.endsAt > e.startsAt, { + message: "endsAt must be after startsAt", + path: ["endsAt"], + }); + +export type GachaEvent = z.infer; + +/** + * Stable, deterministic event ID. + * + * WARNING: these are localStorage keys on the client. Changing this function — + * including the slug rules — orphans every completion mark every user has, with + * no server-side recovery. See docs/DATA-MODEL.md § ID stability. + */ +export function slugify(input: string): string { + return input + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/['‘’]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +export function eventId( + game: GameId, + title: string, + startsAt: string, +): string { + return `${game}:${slugify(title)}:${startsAt.slice(0, 10)}`; +}