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]>
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
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;
|
||
}
|
||
|
||
/**
|
||
* 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}`;
|
||
}
|