diff --git a/src/shared/feed.ts b/src/shared/feed.ts new file mode 100644 index 0000000..e466680 --- /dev/null +++ b/src/shared/feed.ts @@ -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; +export type EventFeed = z.infer; diff --git a/src/shared/games.ts b/src/shared/games.ts new file mode 100644 index 0000000..9825710 --- /dev/null +++ b/src/shared/games.ts @@ -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 = { + 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]; +} diff --git a/src/shared/time.ts b/src/shared/time.ts new file mode 100644 index 0000000..06bb5eb --- /dev/null +++ b/src/shared/time.ts @@ -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 = { + 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; +} diff --git a/test/time.test.ts b/test/time.test.ts new file mode 100644 index 0000000..08aab04 --- /dev/null +++ b/test/time.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import type { GachaEvent } from "../src/shared/schema.ts"; +import { + clockFor, + DAY, + endingSoonestFirst, + formatRemaining, + HOUR, + urgency, +} from "../src/shared/time.ts"; + +const NOW = Date.parse("2026-08-15T12:00:00.000Z"); + +function event(overrides: Partial = {}): GachaEvent { + return { + id: "genshin:x:2026-08-10", + game: "genshin", + title: "X", + type: "other", + summary: null, + startsAt: "2026-08-10T00:00:00.000Z", + startPrecision: "day", + endsAt: "2026-08-20T00:00:00.000Z", + endPrecision: "day", + regionScoped: false, + regionEnds: null, + sourceUrl: "https://example.test/a", + sourceId: "s", + status: "published", + confidence: 0.9, + extractionMethod: "parser", + version: 1, + firstSeenAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("formatRemaining", () => { + test("drops to a finer unit as the deadline closes", () => { + // Days are useless once minutes decide whether you make it. + expect(formatRemaining(9 * DAY + 3 * HOUR)).toBe("9d 3h"); + expect(formatRemaining(4 * HOUR + 12 * 60_000)).toBe("4h 12m"); + expect(formatRemaining(90_000)).toBe("1m 30s"); + expect(formatRemaining(0)).toBe("ended"); + expect(formatRemaining(-5)).toBe("ended"); + }); +}); + +describe("urgency", () => { + test("is driven by absolute time left, not proportion", () => { + expect(urgency(2 * HOUR)).toBe("critical"); + expect(urgency(2 * DAY)).toBe("soon"); + expect(urgency(5 * DAY)).toBe("near"); + expect(urgency(40 * DAY)).toBe("calm"); + expect(urgency(-1)).toBe("expired"); + }); +}); + +describe("clockFor", () => { + test("reports progress through the window", () => { + const c = clockFor(event(), "europe", NOW); + expect(c.live).toBe(true); + expect(c.progress).toBeCloseTo(0.55, 2); + expect(c.msRemaining).toBe(Date.parse("2026-08-20T00:00:00.000Z") - NOW); + }); + + test("an unannounced end is never urgent and has no progress", () => { + // "We don't know" and "loads of time" are different facts. Treating an + // unknown end as a deadline would be inventing one. + const c = clockFor( + event({ endsAt: null, endPrecision: "unknown" }), + "europe", + NOW, + ); + expect(c.msRemaining).toBeNull(); + expect(c.progress).toBeNull(); + expect(c.urgency).toBe("calm"); + expect(c.ended).toBe(false); + }); + + test("resolves a region-scoped end to the reader's region", () => { + const c = clockFor( + event({ + regionScoped: true, + regionEnds: { + asia: "2026-08-20T00:00:00.000Z", + europe: "2026-08-20T07:00:00.000Z", + america: "2026-08-20T13:00:00.000Z", + }, + }), + "america", + NOW, + ); + expect(c.endsMs).toBe(Date.parse("2026-08-20T13:00:00.000Z")); + }); + + test("marks an event that has not started as upcoming", () => { + const c = clockFor( + event({ startsAt: "2026-09-01T00:00:00.000Z", endsAt: "2026-09-10T00:00:00.000Z" }), + "europe", + NOW, + ); + expect(c.upcoming).toBe(true); + expect(c.live).toBe(false); + }); +}); + +describe("endingSoonestFirst", () => { + test("live before upcoming, soonest end first, unknown ends last", () => { + const rows = [ + { key: "upcoming", clock: clockFor(event({ startsAt: "2026-09-01T00:00:00.000Z", endsAt: "2026-09-10T00:00:00.000Z" }), "europe", NOW) }, + { key: "unknown", clock: clockFor(event({ endsAt: null, endPrecision: "unknown" }), "europe", NOW) }, + { key: "later", clock: clockFor(event({ endsAt: "2026-08-25T00:00:00.000Z" }), "europe", NOW) }, + { key: "soonest", clock: clockFor(event({ endsAt: "2026-08-16T00:00:00.000Z" }), "europe", NOW) }, + ]; + expect([...rows].sort(endingSoonestFirst).map((r) => r.key)).toEqual([ + "soonest", + "later", + "unknown", + "upcoming", + ]); + }); +});