feat: add shared time, games and feed contracts
Time is this product's subject, so the vocabulary lives in one module: remaining, progress through a window, and urgency. Urgency is derived from absolute time left, deliberately independent of proportion — a 90-day event with three hours left is as urgent as a 3-day one. An unannounced end is never urgent and has no progress: "we don't know" and "loads of time" are different facts, and conflating them is what this product exists to avoid. Games carry a hue for identity only. Urgency is a separate axis, so one glance answers both "whose event is this?" and "how long have I got?". Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b2feadf5d
commit
45ad5b39da
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { GachaEvent, GameId } from "./schema.ts";
|
||||
|
||||
/**
|
||||
* The wire contract between server and client.
|
||||
*
|
||||
* The client refuses a `schemaVersion` it does not know rather than guessing at
|
||||
* unfamiliar fields. Additive fields do not bump it; removing or retyping one
|
||||
* does. See docs/DATA-MODEL.md § Schema versioning.
|
||||
*/
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
export const SourceHealth = z.object({
|
||||
sourceId: z.string(),
|
||||
game: GameId,
|
||||
url: z.string().url(),
|
||||
lastSuccessAt: z.string().datetime().nullable(),
|
||||
eventCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const EventFeed = z.object({
|
||||
schemaVersion: z.literal(SCHEMA_VERSION),
|
||||
generatedAt: z.string().datetime(),
|
||||
events: z.array(GachaEvent),
|
||||
sources: z.array(SourceHealth),
|
||||
});
|
||||
|
||||
export type SourceHealth = z.infer<typeof SourceHealth>;
|
||||
export type EventFeed = z.infer<typeof EventFeed>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { GameId } from "./schema.ts";
|
||||
|
||||
export interface GameMeta {
|
||||
id: GameId;
|
||||
name: string;
|
||||
/** Short label for narrow lanes and chips. */
|
||||
short: string;
|
||||
/**
|
||||
* Hue identity. This axis encodes *which game* only — urgency is a separate
|
||||
* axis (see time.ts). Keeping them orthogonal is what lets a glance answer
|
||||
* "whose event is this?" and "how long have I got?" at the same time.
|
||||
*/
|
||||
hue: string;
|
||||
}
|
||||
|
||||
export const GAMES: Record<GameId, GameMeta> = {
|
||||
genshin: { id: "genshin", name: "Genshin Impact", short: "Genshin", hue: "#4EA8DE" },
|
||||
hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" },
|
||||
zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" },
|
||||
wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" },
|
||||
arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" },
|
||||
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" },
|
||||
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" },
|
||||
};
|
||||
|
||||
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||
|
||||
export function gameMeta(id: GameId): GameMeta {
|
||||
return GAMES[id];
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { GachaEvent, Region } from "./schema.ts";
|
||||
|
||||
/**
|
||||
* Time is this product's entire subject, so the vocabulary lives in one place:
|
||||
* how long is left, how far through a window we are, and how alarmed to be.
|
||||
*/
|
||||
|
||||
export const MINUTE = 60_000;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
export const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* Server reset offsets from UTC. Gacha regions reset at 04:00 local, which lands
|
||||
* on different UTC instants — collapsing them loses up to 13 hours of accuracy.
|
||||
*/
|
||||
export const REGION_RESET_UTC_OFFSET: Record<Region, number> = {
|
||||
asia: 8, // UTC+8
|
||||
america: -5,
|
||||
europe: 1,
|
||||
};
|
||||
|
||||
export function guessRegion(
|
||||
timeZoneOffsetMinutes: number = -new Date().getTimezoneOffset(),
|
||||
): Region {
|
||||
const hours = timeZoneOffsetMinutes / 60;
|
||||
if (hours <= -2) return "america";
|
||||
if (hours >= 5) return "asia";
|
||||
return "europe";
|
||||
}
|
||||
|
||||
/** The end instant to show this user, honouring a region-scoped event. */
|
||||
export function effectiveEnd(event: GachaEvent, 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;
|
||||
}
|
||||
|
||||
export type Urgency = "expired" | "critical" | "soon" | "near" | "calm";
|
||||
|
||||
/**
|
||||
* Urgency is derived from absolute time remaining, deliberately independent of
|
||||
* how far through the window we are. A 90-day event with 3 hours left is just
|
||||
* as urgent as a 3-day event with 3 hours left.
|
||||
*/
|
||||
export function urgency(msRemaining: number): Urgency {
|
||||
if (msRemaining <= 0) return "expired";
|
||||
if (msRemaining < 24 * HOUR) return "critical";
|
||||
if (msRemaining < 3 * DAY) return "soon";
|
||||
if (msRemaining < 7 * DAY) return "near";
|
||||
return "calm";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact countdown: "4h 12m", "9d 3h", "31m".
|
||||
*
|
||||
* Deliberately drops to a finer unit as the deadline approaches — days are
|
||||
* useless at the point where minutes decide whether you make it.
|
||||
*/
|
||||
export function formatRemaining(msRemaining: number): string {
|
||||
if (msRemaining <= 0) return "ended";
|
||||
|
||||
const days = Math.floor(msRemaining / DAY);
|
||||
const hours = Math.floor((msRemaining % DAY) / HOUR);
|
||||
const minutes = Math.floor((msRemaining % HOUR) / MINUTE);
|
||||
const seconds = Math.floor((msRemaining % MINUTE) / 1000);
|
||||
|
||||
if (days >= 1) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||||
if (hours >= 1) return `${hours}h ${minutes}m`;
|
||||
if (minutes >= 1) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/** Absolute date for the detail view, in the reader's own timezone. */
|
||||
export function formatAbsolute(iso: string, withTime: boolean): string {
|
||||
const d = new Date(iso);
|
||||
const date = d.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
if (!withTime) return date;
|
||||
return `${date}, ${d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
|
||||
}
|
||||
|
||||
export interface EventClock {
|
||||
startsMs: number;
|
||||
endsMs: number | null;
|
||||
msRemaining: number | null;
|
||||
/** 0–1 through the event's own window. Null when the end is unknown. */
|
||||
progress: number | null;
|
||||
urgency: Urgency;
|
||||
live: boolean;
|
||||
upcoming: boolean;
|
||||
ended: boolean;
|
||||
}
|
||||
|
||||
export function clockFor(
|
||||
event: GachaEvent,
|
||||
region: Region,
|
||||
now: number,
|
||||
): EventClock {
|
||||
const startsMs = Date.parse(event.startsAt);
|
||||
const end = effectiveEnd(event, region);
|
||||
const endsMs = end === null ? null : Date.parse(end);
|
||||
|
||||
const msRemaining = endsMs === null ? null : endsMs - now;
|
||||
const upcoming = now < startsMs;
|
||||
const ended = msRemaining !== null && msRemaining <= 0;
|
||||
|
||||
let progress: number | null = null;
|
||||
if (endsMs !== null && endsMs > startsMs) {
|
||||
progress = Math.min(1, Math.max(0, (now - startsMs) / (endsMs - startsMs)));
|
||||
}
|
||||
|
||||
return {
|
||||
startsMs,
|
||||
endsMs,
|
||||
msRemaining,
|
||||
progress,
|
||||
// An event with no announced end is never treated as urgent — we do not
|
||||
// know that it is ending, and pretending otherwise would be a guess.
|
||||
urgency: msRemaining === null ? "calm" : urgency(msRemaining),
|
||||
live: !upcoming && !ended,
|
||||
upcoming,
|
||||
ended,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sort key: live events by soonest end, then upcoming by soonest start. */
|
||||
export function endingSoonestFirst(
|
||||
a: { clock: EventClock },
|
||||
b: { clock: EventClock },
|
||||
): number {
|
||||
if (a.clock.upcoming !== b.clock.upcoming) return a.clock.upcoming ? 1 : -1;
|
||||
if (a.clock.upcoming) return a.clock.startsMs - b.clock.startsMs;
|
||||
// Unknown ends sort last among live events: they are real, but they are not
|
||||
// the thing the reader is here to worry about.
|
||||
if (a.clock.msRemaining === null) return b.clock.msRemaining === null ? 0 : 1;
|
||||
if (b.clock.msRemaining === null) return -1;
|
||||
return a.clock.msRemaining - b.clock.msRemaining;
|
||||
}
|
||||
Reference in New Issue
Block a user