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]>
416 lines
16 KiB
TypeScript
416 lines
16 KiB
TypeScript
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}`;
|
||
}
|