Files
gacha-event-tracker/src/shared/time.ts
T
Lucas WintherandClaude Opus 5.5 50c6e91b00 feat: the redesigned Event Clock
The new reader app replaces the old UI: Checklist with a rail (focus
pills, Next to expire, today's dailies, pinned routines), Timeline in a
new and a classic look, Routines, Archive and a Settings area, in a
Structured (default) and a Relaxed interface. It follows the approved
Round 6 B and Round 7 mockups, corrected screen by screen with the owner.

Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data
is migrated on first load, a recovery copy is written first, and the v1
keys are never removed, so a rollback finds them unchanged. Redeem codes
for Genshin arrive as their own feed.

Also: live's urgency ramp, editable per theme; completion and
section-complete fireworks; a returning-user tour; the design mockups,
specs, plans, reviews and handoff record.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:55:16 +02:00

416 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DisplayEvent, LaneId } from "./custom.ts";
import { GAMES } from "./games.ts";
import { Region } from "./schema.ts";
import type { GameId, Precision } 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 interface ResetTiming { offsetUtc: number; resetHour: number; resetMinute: number }
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,
};
/** What a region is called when the reader is shown one. */
export const REGION_LABEL: Record<Region, string> = {
america: "America",
europe: "Europe",
asia: "Asia",
};
/**
* Gacha servers roll the day at 04:00 local server time, not midnight — a
* player finishing at 02:00 is still on the previous day's dailies. Getting
* this wrong ticks the wrong box for four hours every night.
*/
export const RESET_HOUR_LOCAL = 4;
/**
* The UTC offset of the server clock a reader's day rolls on.
*
* The reader's region is always the question; a game can just answer it
* differently. Most run a server per region and take the default. One that
* serves two regions off a single machine lists the regions that differ in
* `resetOffsets` — Endfield's European players sit on the Americas server, so
* `europe` resolves to UTC-5 there and to UTC+1 everywhere else.
*
* A blanket per-game offset would be the wrong shape: it would drag the regions
* 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?: 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];
}
/**
* The hour of its own server day a game rolls over on, for the reader's region.
*
* Almost always `RESET_HOUR_LOCAL`. A game that resets on a different hour says
* so in `resetHourLocal` (`games.ts`), per region because a game's servers need
* not agree — Reverse: 1999 rolls at 05:00 everywhere — and that
* cannot be folded into `serverOffsetUtc`: shifting a game's stated offset to
* land the right reset instant would misreport the server clock to everything
* else that asks for it.
*
* A lane the reader invented has no server to know about and takes the default,
* for the same reason `serverOffsetUtc` does.
*/
export function resetHourFor(region: Region, game?: LaneId): number {
const override =
game === undefined ? undefined : GAMES[game as GameId]?.resetHourLocal?.[region];
return override ?? RESET_HOUR_LOCAL;
}
/**
* Offset from UTC midnight to this game's reset instant.
*
* `dayKey` and everything downstream of it is a **localStorage key**. Moving the
* reset hour, a region offset, or a game's own override re-labels the game-day
* some already-logged ticks fall in — at most by one day, and never by deleting
* one, but it is still the reader's streak moving under them. Treat a change
* here as a data change, not a constant.
*/
export function resetShiftMs(region: Region, game?: LaneId, timing?: ResetTiming): number {
return timing === undefined ? serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR : timing.offsetUtc * HOUR - timing.resetHour * HOUR - timing.resetMinute * MINUTE;
}
/**
* The instant the game-day labelled `day` (`YYYY-MM-DD`) opens on — the inverse
* of `dayKey`.
*/
export function dayStartMs(day: string, region: Region, game?: LaneId, timing?: ResetTiming): number {
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game, timing);
}
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 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" | "endPrecision" | "regionScoped" | "regionEnds"
>;
/**
* The end to show this user, honouring a region-scoped event, with the
* precision that boundary was actually stated to.
*
* A `regionEnds` value is read as `exact` whatever `endPrecision` says about the
* fallback: the map only exists because the source published a timer per server
* (wiki.gg does; see AGENTS.md § Working on parsers), so there is a real instant
* in it and nothing left to resolve.
*/
function endBoundary(
event: EndBearing,
region: Region,
): { iso: string; precision: Precision } | null {
const stated =
event.regionScoped && event.regionEnds !== null
? event.regionEnds[region]
: undefined;
if (stated !== undefined && stated !== null) {
return { iso: stated, precision: "exact" };
}
return event.endsAt === null
? null
: { iso: event.endsAt, precision: event.endPrecision };
}
/** The end instant to show this user, honouring a region-scoped event. */
export function effectiveEnd(
event: EndBearing,
region: Region,
): string | null {
return endBoundary(event, region)?.iso ?? null;
}
/** Everything the clock reads off an event. */
export type Clockable = EndBearing &
Pick<
DisplayEvent,
"startsAt" | "startPrecision" | "game" | "extractionMethod"
>;
/**
* The instant a stated boundary actually falls on.
*
* An `exact` boundary is already an instant and is returned untouched. A `day`
* one is not an instant at all: the source printed a calendar date and no time,
* and `dates.ts` stores the only faithful reading of that — 00:00Z, a placeholder
* for "somewhere in this day" rather than a claim about when the day starts.
*
* Counting down to that placeholder turns it into exactly the claim it was not:
* that the day begins at UTC midnight, which is nobody's day. It is wrong for
* every reader by their server's distance from UTC, and always in the direction
* of expiring an event early for the two regions that run ahead of it. Wuthering
* Waves events dated "August 19, 2026" were still running three hours after this
* countdown had retired them, because a European player's day opens at 04:00 on
* a UTC+1 server.
*
* So a day-precision boundary resolves to the reset that opens that game-day on
* the clock the game actually rolls on — the one fact we do hold about a game's
* day, and the same one `daily.ts` already keys every tick by. That is a reading
* of the date the source printed, not a time invented for it: the calendar day
* is still the source's, and an end whose day is genuinely unannounced is still
* `null`.
*
* A reader's own event (PRD F13) is left alone even at day precision. Its
* boundary is not a parser declining to guess — `readerInstant` resolved it to
* the instant they meant, in their own timezone, when they typed it.
*/
function boundaryMs(
iso: string,
precision: Precision,
event: Pick<Clockable, "game" | "extractionMethod">,
region: Region,
timing?: ResetTiming,
): number {
if (precision !== "day" || event.extractionMethod !== "parser") {
return Date.parse(iso);
}
return dayStartMs(iso.slice(0, 10), region, event.game, timing);
}
/**
* The instant a printed boundary has passed for **every** reader, whatever
* region they are on.
*
* `boundaryMs` above answers the question for one reader; this answers it for
* the last of them. The two must agree, because they are read at opposite ends
* of the same pipeline: an ingest parser deciding whether a row is still worth
* publishing, and the countdown deciding whether to call it over.
*
* They did not. A parser comparing `Date.parse(endsAt)` against `now` retires a
* day-precision end at UTC midnight — the placeholder, not an instant (§ Domain
* rules) — while the app keeps the event live until the reset that opens that
* game-day on the reader's own server. For a default server map that is 09:00Z
* in the Americas, so the feed drops an event nine hours before the app, the
* countdown and the game all agree it is over. The reader does not see a stale
* row; they see the deadline they were counting down to vanish on its last day,
* which is the silent drop AGENTS.md § Working on parsers calls the dangerous
* failure.
*
* So a row is history only once it is history everywhere. Being generous by a
* few hours costs an expired row at the bottom of a list; being strict costs a
* live one.
*/
export function latestBoundaryMs(
iso: string,
precision: Precision,
game?: LaneId,
): number {
if (precision !== "day") return Date.parse(iso);
const day = iso.slice(0, 10);
return Math.max(...Region.options.map((r) => dayStartMs(day, r, game)));
}
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.
*
* Takes an instant rather than only an ISO string so a caller with a resolved
* boundary can print the one the countdown is counting to. A day-precision date
* read straight off the event says 00:00Z, which renders as the *previous* day
* to every reader west of UTC — the sheet would then name one day while the
* timer beside it ran to another.
*/
export function formatAbsolute(iso: string | number, 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;
// The time is one reading: a line break inside it strands "AM" on a line of
// its own, so its spaces are made non-breaking.
const time = d
.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
.replace(/\s/g, " ");
return `${date}, ${time}`;
}
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: Clockable,
region: Region,
now: number,
timing?: ResetTiming,
): EventClock {
const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region, timing);
const end = endBoundary(event, region);
const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region, timing);
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;
}
/**
* Sort key: soonest deadline first, and nothing else.
*
* The other half of the timeline's "put them all together" (PRD F1). Where
* `endingSoonestFirst` holds every unstarted event behind every running one —
* which is what a *checklist* wants, since you cannot do a thing that has not
* opened — this asks the one question a Gantt board is for: what runs out
* first. An event opening on Friday and closing on Sunday is a nearer deadline
* than one running now until October, and a reader looking at a calendar rather
* than a to-do list is entitled to see it in that order.
*
* The `endsAt: null` rule is the same in both: an unannounced end is real but is
* not a deadline, so it sorts behind every dated row rather than claiming a
* place in the queue.
*/
export function byDeadline(
a: { clock: EventClock },
b: { clock: EventClock },
): number {
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;
}
/**
* A plain-language caption for an event's window.
*
* The meter shows a proportion; this says what the proportion is *of*. Without
* it a reader has to infer that ticks mean remaining time, which is exactly the
* kind of "obvious to the author" encoding that leaves everyone else guessing.
*/
export function windowCaption(clock: EventClock, now: number): string {
const on = (ms: number) =>
new Date(ms).toLocaleDateString(undefined, { day: "numeric", month: "short" });
if (clock.upcoming) {
return clock.endsMs === null
? `starts ${on(clock.startsMs)}`
: `${on(clock.startsMs)} – ${on(clock.endsMs)} · not started yet`;
}
if (clock.endsMs === null) {
return `started ${on(clock.startsMs)} · no end date announced`;
}
const totalDays = Math.max(
1,
Math.round((clock.endsMs - clock.startsMs) / DAY),
);
const leftMs = Math.max(0, clock.endsMs - now);
const leftDays = Math.ceil(leftMs / DAY);
const left =
leftMs < DAY
? `${Math.max(1, Math.floor(leftMs / HOUR))} of ${totalDays * 24} hours left`
: `${leftDays} of ${totalDays} days left`;
return `${on(clock.startsMs)} – ${on(clock.endsMs)} · ${left}`;
}