A task is a period that rolls on the game's reset and a measure that fills it: days ticked, or a number against a target. Its status is the goal status shape, so the dailies strip treats it like any other item. Its ids get their own reserved space, as do the built-ins to come. Co-Authored-By: Claude Opus 5.5 <[email protected]>
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
comesRoundEarly,
|
|
isOccurrenceId,
|
|
nearestOccurrence,
|
|
occurrenceForId,
|
|
Repeat,
|
|
ruleIdOf,
|
|
} from "./recurrence.ts";
|
|
import type { Occurrence } from "./recurrence.ts";
|
|
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, `mytask:` is a weekly or monthly task they
|
|
* set up (task.ts), `routine:` is held for the built-in ones, 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",
|
|
"mytask",
|
|
"routine",
|
|
] 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.
|
|
*
|
|
* **`z.string()` and not `GameId`, deliberately.** We retire games, sources
|
|
* and pages routinely, and this field is the only thing standing between
|
|
* that and a reader losing a row they typed: `useCustom` reads through
|
|
* `validRecords`, which drops a record that fails this schema, and the
|
|
* survivors are what the next write persists. Narrowing this to the enum
|
|
* would read as a tightening and would arm every future removal to erase
|
|
* reader data on next launch — silently, with no server-side recovery,
|
|
* exactly as § Event IDs are localStorage keys describes for `slugify`.
|
|
* `metaFor` is total so an id with no game behind it still renders.
|
|
*/
|
|
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,
|
|
|
|
/**
|
|
* How this comes round again, or null when it does not.
|
|
*
|
|
* **`.default(null)`, never a bare `.nullable()`.** A record written before
|
|
* this field existed has no `repeat` key at all, and a bare `.nullable()`
|
|
* rejects a *missing* key rather than supplying one. `useCustom` reads
|
|
* through `validRecords`, which drops what fails this schema, and the
|
|
* survivors are what the next write persists — so the stricter form would
|
|
* erase every reader's custom events on first launch, silently, with no
|
|
* server-side copy. The same hazard `game: z.string()` above is guarding.
|
|
*/
|
|
repeat: Repeat.nullable().default(null),
|
|
|
|
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"],
|
|
})
|
|
// A window that has not closed by the time it comes round again puts two
|
|
// live occurrences of one rule in the same list, and "what ends soonest" no
|
|
// longer has an answer. Checked only when an end is stated: with none, the
|
|
// window runs to the next opening by definition and cannot overlap.
|
|
.refine(
|
|
(e) =>
|
|
!comesRoundEarly(
|
|
Date.parse(e.startsAt),
|
|
e.endsAt === null ? null : Date.parse(e.endsAt),
|
|
e.repeat,
|
|
),
|
|
{
|
|
message: "a repeat cannot come round before it ends",
|
|
path: ["repeat"],
|
|
},
|
|
);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Project one occurrence of a rule into the shape the views read.
|
|
*
|
|
* Everything that identifies the *thing* comes from the rule; everything that
|
|
* identifies *which time round* comes from the occurrence. Nothing downstream
|
|
* is told which it is looking at, which is what lets sort, focus, lanes,
|
|
* filters, progress, ignores and the daily checklist work with no narrowing at
|
|
* any call site.
|
|
*
|
|
* The end is always resolved here, never `null`. A rule with no stated end
|
|
* stores `null` and means "until the next one opens"; a row that reached a view
|
|
* still carrying the unresolved form would render as live-with-unknown-end
|
|
* forever, which is the failure this whole design exists to avoid.
|
|
*/
|
|
export function asOccurrenceEvent(
|
|
rule: CustomEvent,
|
|
occurrence: Occurrence,
|
|
): DisplayEvent {
|
|
return {
|
|
...asDisplayEvent(rule),
|
|
id: occurrence.id,
|
|
startsAt: occurrence.startsAt,
|
|
startPrecision: occurrence.startPrecision,
|
|
endsAt: occurrence.endsAt,
|
|
endPrecision: occurrence.endPrecision,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The stored record a row belongs to, whichever kind of id it carries.
|
|
*
|
|
* A row may be one occurrence of a rule, whose id carries a `#` suffix and is
|
|
* deliberately not a key in the store. Marks, ignores and ticks key off that
|
|
* suffixed id — each time round has its own completion — but there is only ever
|
|
* one record to edit, and it is the rule.
|
|
*
|
|
* Total, and safe for a feed id: `ruleIdOf` returns anything without a
|
|
* separator unchanged, and a feed id is simply not in this store.
|
|
*/
|
|
export function recordFor(
|
|
events: CustomEvents,
|
|
rowId: string,
|
|
): CustomEvent | undefined {
|
|
return events[ruleIdOf(rowId)];
|
|
}
|
|
|
|
/**
|
|
* The row an id names, for anything the reader entered themselves.
|
|
*
|
|
* The detail sheet opens rows, and an id reaches it from three places that do
|
|
* not agree about shape: a list row carries an occurrence id, the settings
|
|
* index carries a stored rule id, and a plain event's id is both at once.
|
|
* This is the one place that reconciles them, and it is exported rather than
|
|
* inlined because nothing in this project can click: no test renders the app,
|
|
* so an id the sheet cannot resolve was invisible to the whole suite until it
|
|
* had already shipped as a button that does nothing.
|
|
*
|
|
* **The last branch is a floor, not a nicety.** A rule can legitimately
|
|
* produce no occurrence at all — `until` earlier than `startsAt` parses fine
|
|
* and is reachable by import — which puts it on no list, no board and no
|
|
* timeline. Returning null for it would leave the settings index opening
|
|
* nothing for exactly the record that index exists to rescue. Falling back to
|
|
* the rule itself means a stored id always names something the reader can
|
|
* edit and delete.
|
|
*/
|
|
export function displayEventFor(
|
|
events: CustomEvents,
|
|
rowId: string,
|
|
nowMs: number,
|
|
): DisplayEvent | null {
|
|
const record = recordFor(events, rowId);
|
|
if (record === undefined) return null;
|
|
|
|
if (record.repeat === null) return asDisplayEvent(record);
|
|
|
|
const occurrence = isOccurrenceId(rowId)
|
|
? occurrenceForId(record, rowId)
|
|
: nearestOccurrence(record, nowMs);
|
|
return occurrence === null
|
|
? asDisplayEvent(record)
|
|
: asOccurrenceEvent(record, occurrence);
|
|
}
|