Files
gacha-event-tracker/src/shared/time.ts
T
Lucas WintherandClaude Opus 5 f4b133274a fix: say what the bars and colours mean
The meter encoded two things at once and neither was guessable from looking
at it — obvious to whoever built it, opaque to everyone else.

Every row now carries a plain caption under the bar ("Aug 10 – Aug 17 · 3 of
7 days left", "started Apr 22 · no end date announced"), which explains the
bar and doubles as the exact dates people wanted anyway. A legend above the
list explains the colour ramp, which no per-row text can — shown once,
because a legend repeated fifty times is noise.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:09:54 +02:00

179 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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;
/** 01 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;
}
/**
* 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}`;
}