feat: add the Event Clock interface

Opens on the single event closest to expiring, at a size nothing else
competes with — the reader arrives with one question. Below it, live events
ordered by what ends soonest, then a quiet timeline view with one lane per
game.

Countdowns use tabular figures so ticking never reflows the row.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:28:45 +02:00
co-authored by Claude Opus 5
parent ec8cc97c1a
commit b1d18eaf59
7 changed files with 885 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
import { useEffect, useMemo, useState } from "react";
import { fetchFeed, type FeedState } from "./api.ts";
import { Controls } from "./components/Controls.tsx";
import { EventDetail } from "./components/EventDetail.tsx";
import { EventRow, type RowEvent } from "./components/EventRow.tsx";
import { NextUp } from "./components/NextUp.tsx";
import { Timeline } from "./components/Timeline.tsx";
import { useCompletions } from "./state/useCompletions.ts";
import { usePrefs } from "./state/usePrefs.ts";
import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts";
import type { GameId } from "../shared/schema.ts";
type View = "soon" | "calendar";
/** Ticks once a second so countdowns stay honest without re-fetching. */
function useNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
export function App() {
const [state, setState] = useState<FeedState>({ status: "loading" });
const [view, setView] = useState<View>("soon");
const [openId, setOpenId] = useState<string | null>(null);
const now = useNow();
const { prefs, update, toggleGame } = usePrefs();
const { completions, toggle, merge } = useCompletions();
useEffect(() => {
const ac = new AbortController();
fetchFeed(ac.signal)
.then((feed) => setState({ status: "ready", feed }))
.catch((err: unknown) => {
if (ac.signal.aborted) return;
setState({
status: "error",
message: err instanceof Error ? err.message : "Could not load events.",
});
});
return () => ac.abort();
}, []);
const allRows = useMemo<RowEvent[]>(() => {
if (state.status !== "ready") return [];
return state.feed.events
.filter((e) => e.status === "published")
.map((event) => ({ event, clock: clockFor(event, prefs.region, now) }));
// `now` intentionally excluded: recomputing every clock each second is
// wasteful, and the countdown text re-renders from `now` anyway.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, prefs.region, Math.floor(now / 60_000)]);
const games = useMemo<GameId[]>(
() => [...new Set(allRows.map((r) => r.event.game))],
[allRows],
);
const visible = useMemo(
() =>
allRows
.filter((r) => !prefs.hiddenGames.includes(r.event.game))
.filter((r) => !r.clock.ended)
.filter((r) => prefs.showCompleted || completions[r.event.id] === undefined)
.sort(endingSoonestFirst),
[allRows, prefs.hiddenGames, prefs.showCompleted, completions],
);
const live = visible.filter((r) => r.clock.live);
const upcoming = visible.filter((r) => r.clock.upcoming);
const next = live.find((r) => r.clock.msRemaining !== null) ?? live[0] ?? null;
const openRow = allRows.find((r) => r.event.id === openId) ?? null;
if (state.status === "loading") {
return <Shell><p className="px-4 py-16 text-sm text-muted">Loading events</p></Shell>;
}
if (state.status === "error") {
return (
<Shell>
<div className="px-4 py-16">
<p className="eyebrow text-critical">Events unavailable</p>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted">
{state.message}
</p>
</div>
</Shell>
);
}
const staleSources = state.feed.sources.filter(
(s) => s.lastSuccessAt === null || now - Date.parse(s.lastSuccessAt) > 2 * DAY,
);
return (
<Shell>
<header className="flex items-center justify-between px-4 pb-3 pt-5">
<div>
<p className="font-display text-[0.9375rem] font-bold tracking-[0.02em]">
EVENT<span className="text-near">CLOCK</span>
</p>
<p className="mt-0.5 text-xs text-faint">
{live.length} live · {upcoming.length} upcoming
</p>
</div>
<div
role="tablist"
aria-label="View"
className="flex rounded-lg border border-hairline p-0.5"
>
{(
[
["soon", "Ending soon"],
["calendar", "Calendar"],
] as const
).map(([id, label]) => (
<button
key={id}
role="tab"
aria-selected={view === id}
onClick={() => setView(id)}
className={`rounded-[6px] px-2.5 py-1.5 text-xs font-medium transition-colors ${
view === id ? "bg-raised text-ink" : "text-faint hover:text-muted"
}`}
>
{label}
</button>
))}
</div>
</header>
{view === "soon" ? (
<>
<NextUp row={next} onOpen={setOpenId} />
{live.length > 0 && (
<Section
title="Running now"
hint={
live.length > 1
? `next after this ends in ${formatRemaining(
live[1]?.clock.msRemaining ?? 0,
)}`
: undefined
}
>
{live.map((row) => (
<EventRow
key={row.event.id}
row={row}
completed={completions[row.event.id] !== undefined}
onToggle={toggle}
onOpen={setOpenId}
/>
))}
</Section>
)}
{upcoming.length > 0 && (
<Section title="Not started yet">
{upcoming.map((row) => (
<EventRow
key={row.event.id}
row={row}
completed={completions[row.event.id] !== undefined}
onToggle={toggle}
onOpen={setOpenId}
/>
))}
</Section>
)}
{visible.length === 0 && (
<p className="px-4 py-12 text-sm leading-relaxed text-muted">
Nothing to show. Every game is switched off, or you've finished
everything and hidden completed events.
</p>
)}
</>
) : (
<Timeline
rows={visible}
now={now}
onOpen={setOpenId}
completions={completions}
/>
)}
<Controls
games={games}
prefs={prefs}
onToggleGame={toggleGame}
onUpdate={update}
onExport={() => exportProgress(completions, prefs)}
onImport={(file) => void importProgress(file, merge)}
/>
<footer className="px-4 pb-10 pt-2 text-xs leading-relaxed text-faint">
<p>
Dates come from community wikis and are shown in your local time. Every
event links to its source — check there before the last hours.
</p>
{staleSources.length > 0 && (
<p className="mt-2 text-soon">
{staleSources.length} source
{staleSources.length > 1 ? "s have" : " has"} not refreshed in over two
days. Some end dates may have moved.
</p>
)}
</footer>
{openRow !== null && (
<EventDetail
row={openRow}
completed={completions[openRow.event.id] !== undefined}
onToggle={toggle}
onClose={() => setOpenId(null)}
/>
)}
</Shell>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x">
{children}
</div>
);
}
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string | undefined;
children: React.ReactNode;
}) {
return (
<section className="pt-5">
<div className="flex items-baseline justify-between gap-3 px-4 pb-2">
<h2 className="eyebrow">{title}</h2>
{hint !== undefined && <p className="text-xs text-faint">{hint}</p>}
</div>
<ul className="border-t border-hairline">{children}</ul>
</section>
);
}
function exportProgress(
completions: Record<string, { completedAt: string }>,
prefs: unknown,
) {
const blob = new Blob(
[
JSON.stringify(
{
format: "gacha-tracker-export",
version: 1,
exportedAt: new Date().toISOString(),
completions,
prefs,
},
null,
2,
),
],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `event-clock-progress-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
async function importProgress(
file: File,
merge: (c: Record<string, { completedAt: string }>) => void,
) {
try {
const parsed: unknown = JSON.parse(await file.text());
const data = parsed as { format?: string; completions?: unknown };
if (data.format !== "gacha-tracker-export") {
alert("That file isn't an Event Clock export.");
return;
}
if (typeof data.completions === "object" && data.completions !== null) {
merge(data.completions as Record<string, { completedAt: string }>);
}
} catch {
alert("That file couldn't be read. Export a fresh copy and try again.");
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { GameId, Region } from "../../shared/schema.ts";
import { gameMeta } from "../../shared/games.ts";
import type { Prefs } from "../state/usePrefs.ts";
const REGIONS: Array<{ id: Region; label: string }> = [
{ id: "america", label: "America" },
{ id: "europe", label: "Europe" },
{ id: "asia", label: "Asia" },
];
export function Controls({
games,
prefs,
onToggleGame,
onUpdate,
onExport,
onImport,
}: {
games: GameId[];
prefs: Prefs;
onToggleGame: (g: GameId) => void;
onUpdate: (p: Partial<Prefs>) => void;
onExport: () => void;
onImport: (file: File) => void;
}) {
return (
<section className="border-t border-hairline px-4 py-5">
<p className="eyebrow">Games</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{games.map((id) => {
const game = gameMeta(id);
const on = !prefs.hiddenGames.includes(id);
return (
<button
key={id}
type="button"
onClick={() => onToggleGame(id)}
aria-pressed={on}
className="rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: on ? game.hue : "var(--color-hairline)",
color: on ? game.hue : "var(--color-faint)",
background: on
? `color-mix(in srgb, ${game.hue} 12%, transparent)`
: "transparent",
}}
>
{game.short}
</button>
);
})}
</div>
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<p className="eyebrow">Server region</p>
<div className="mt-2 flex gap-1.5">
{REGIONS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onUpdate({ region: r.id, regionConfirmed: true })}
aria-pressed={prefs.region === r.id}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
prefs.region === r.id
? "border-ink/70 text-ink"
: "border-hairline text-faint hover:text-muted"
}`}
>
{r.label}
</button>
))}
</div>
</div>
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.showCompleted}
onChange={(e) => onUpdate({ showCompleted: e.target.checked })}
className="size-4 accent-[var(--color-near)]"
/>
Show events I've finished
</label>
</div>
<div className="mt-6 border-t border-hairline pt-4">
<p className="eyebrow">Your progress</p>
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
Completed events are saved in this browser only there is no account.
Move them to another device with a file.
</p>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={onExport}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Export
</button>
<label className="cursor-pointer rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink">
Import
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onImport(file);
e.target.value = "";
}}
/>
</label>
</div>
</div>
</section>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect } from "react";
import { gameMeta } from "../../shared/games.ts";
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export function EventDetail({
row,
completed,
onToggle,
onClose,
}: {
row: RowEvent;
completed: boolean;
onToggle: (id: string) => void;
onClose: () => void;
}) {
const { event, clock } = row;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<button
type="button"
aria-label="Close details"
onClick={onClose}
className="absolute inset-0 bg-ground/80 backdrop-blur-sm"
/>
<div
role="dialog"
aria-modal="true"
aria-label={event.title}
className="relative max-h-[88vh] w-full overflow-y-auto rounded-t-2xl border border-hairline bg-surface p-5 sm:max-w-lg sm:rounded-2xl"
>
<p className="eyebrow" style={{ color: game.hue }}>
{game.name}
</p>
<h2 className="mt-1.5 font-display text-xl font-semibold leading-snug">
{event.title}
</h2>
{event.summary !== null && (
<p className="mt-2 text-sm leading-relaxed text-muted">{event.summary}</p>
)}
<div className="mt-4">
<Meter
progress={clock.progress}
urgency={clock.urgency}
label="Time remaining"
animate={false}
/>
</div>
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<Field label="Starts">
{formatAbsolute(event.startsAt, event.startPrecision === "exact")}
</Field>
<Field label="Ends">
{event.endsAt === null ? (
<span className="text-faint">Not announced</span>
) : (
formatAbsolute(event.endsAt, event.endPrecision === "exact")
)}
</Field>
<Field label="Remaining">
<span className="tnum font-display" style={{ color: heat }}>
{clock.msRemaining === null
? "unknown"
: formatRemaining(clock.msRemaining)}
</span>
</Field>
<Field label="Type">{event.type}</Field>
</dl>
{event.endPrecision === "day" && event.endsAt !== null && (
<p className="mt-3 text-xs leading-relaxed text-faint">
The source gave a date but no time of day, so this end is accurate to
the day only. Check in-game before the last hours.
</p>
)}
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={() => onToggle(event.id)}
className={`flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors ${
completed
? "border-hairline text-muted hover:text-ink"
: "border-transparent bg-ink text-ground hover:bg-white"
}`}
>
{completed ? "Mark not done" : "Mark done"}
</button>
<a
href={event.sourceUrl}
target="_blank"
rel="noreferrer noopener"
className="rounded-lg border border-hairline px-4 py-2.5 text-sm text-muted transition-colors hover:text-ink"
>
Source
</a>
</div>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="eyebrow">{label}</dt>
<dd className="mt-0.5">{children}</dd>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { gameMeta } from "../../shared/games.ts";
import type { GachaEvent } from "../../shared/schema.ts";
import { formatRemaining, type EventClock } from "../../shared/time.ts";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export interface RowEvent {
event: GachaEvent;
clock: EventClock;
}
interface EventRowProps {
row: RowEvent;
completed: boolean;
onToggle: (id: string) => void;
onOpen: (id: string) => void;
}
export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
const { event, clock } = row;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
const countdown = clock.upcoming
? `starts in ${formatRemaining(clock.startsMs - Date.now())}`
: clock.msRemaining === null
? "end date unknown"
: formatRemaining(clock.msRemaining);
return (
<li
className={`group relative flex gap-3 border-b border-hairline/70 px-4 py-3.5 transition-opacity ${
completed ? "opacity-40" : ""
}`}
>
{/* Game identity: a hue stripe, never an urgency colour. */}
<span
aria-hidden
className="mt-1 w-[3px] shrink-0 rounded-full"
style={{ background: game.hue }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<button
type="button"
onClick={() => onOpen(event.id)}
className="min-w-0 text-left"
>
<span
className="eyebrow block truncate"
style={{ color: game.hue }}
>
{game.short}
</span>
<span
className={`block truncate text-[0.9375rem] font-medium leading-snug ${
completed ? "line-through decoration-faint" : ""
}`}
>
{event.title}
</span>
</button>
<span
className="tnum shrink-0 font-display text-sm font-semibold tabular-nums"
style={{ color: clock.msRemaining === null ? "var(--color-faint)" : heat }}
>
{countdown}
</span>
</div>
<div className="mt-2.5">
<Meter
progress={clock.upcoming ? 1 : clock.progress}
urgency={clock.urgency}
label={`${event.title}: ${countdown}`}
/>
</div>
</div>
<button
type="button"
onClick={() => onToggle(event.id)}
aria-pressed={completed}
aria-label={completed ? `Mark ${event.title} not done` : `Mark ${event.title} done`}
className={`mt-0.5 grid size-7 shrink-0 place-items-center self-center rounded-md border transition-colors ${
completed
? "border-transparent bg-near/20 text-near"
: "border-hairline text-faint hover:border-faint hover:text-muted"
}`}
>
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<path
d="M2.5 8.5l3.5 3.5 7.5-8"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</li>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { gameMeta } from "../../shared/games.ts";
import { formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
/**
* The thesis of the page: this app is a clock, so the first thing you see is
* the single event closest to expiring, at a size nothing else competes with.
*
* Deliberately not a stat grid. One number, because the reader has exactly one
* question on arrival.
*/
export function NextUp({ row, onOpen }: { row: RowEvent | null; onOpen: (id: string) => void }) {
if (row === null) {
return (
<section className="border-b border-hairline px-4 py-8">
<p className="eyebrow">Nothing running</p>
<p className="mt-2 max-w-sm text-sm text-muted">
No live events in the games you have switched on. Turn a game back on
below, or check again after the next patch.
</p>
</section>
);
}
const { event, clock } = row;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
const known = clock.msRemaining !== null;
return (
<section className="relative overflow-hidden border-b border-hairline px-4 pb-6 pt-5">
{/* A wash of the urgency colour, so the panel itself changes temperature
as the deadline closes in. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -top-24 h-48 opacity-[0.16] blur-2xl"
style={{ background: `radial-gradient(60% 100% at 50% 100%, ${heat}, transparent)` }}
/>
<div className="relative">
<p className="eyebrow">Next to expire</p>
<button
type="button"
onClick={() => onOpen(event.id)}
className="mt-2 block max-w-full text-left"
>
<h1 className="font-display text-[1.75rem] font-semibold leading-[1.15] tracking-tight">
{event.title}
</h1>
<p className="mt-1 text-sm" style={{ color: game.hue }}>
{game.name}
</p>
</button>
<div className="mt-5 flex items-end justify-between gap-4">
<p
className="tnum font-display text-[2.75rem] font-bold leading-none tracking-tight"
style={{ color: known ? heat : "var(--color-faint)" }}
>
{known ? formatRemaining(clock.msRemaining ?? 0) : "unknown"}
</p>
<p className="pb-1 text-right text-xs leading-tight text-muted">
{known ? "left" : "no end date"}
<br />
{known ? "to finish it" : "announced"}
</p>
</div>
<div className="mt-4">
<Meter
progress={clock.progress}
urgency={clock.urgency}
label={`${event.title} time remaining`}
/>
</div>
</div>
</section>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { gameMeta } from "../../shared/games.ts";
import type { GameId } from "../../shared/schema.ts";
import { DAY } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { URGENCY_COLOR } from "./Meter.tsx";
const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once
/**
* One lane per game, bars spanning start→end, today pinned as a rule.
*
* The quiet view. The ending-soon list carries the page's boldness, so this
* stays flat and legible: no gradients, no rounded chrome, just position and
* length doing the work.
*/
export function Timeline({
rows,
now,
onOpen,
completions,
}: {
rows: RowEvent[];
now: number;
onOpen: (id: string) => void;
completions: Record<string, unknown>;
}) {
if (rows.length === 0) {
return (
<p className="px-4 py-10 text-sm text-muted">
Nothing to plot. Switch a game back on to see its schedule.
</p>
);
}
const starts = rows.map((r) => r.clock.startsMs);
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
const min = Math.min(...starts, now) - 2 * DAY;
const max = Math.max(...ends, now) + 2 * DAY;
const totalDays = Math.ceil((max - min) / DAY);
const width = totalDays * DAY_WIDTH;
const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH;
const byGame = new Map<GameId, RowEvent[]>();
for (const row of rows) {
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
}
const monthTicks = monthBoundaries(min, max);
return (
<div className="scroll-x">
<div style={{ width, minWidth: "100%" }} className="relative px-4 pb-8 pt-3">
{/* Month rule, so a bar's absolute position means something. */}
<div className="relative mb-3 h-4 border-b border-hairline">
{monthTicks.map((t) => (
<span
key={t.ms}
className="eyebrow absolute -translate-x-px whitespace-nowrap border-l border-hairline pl-1"
style={{ left: x(t.ms) }}
>
{t.label}
</span>
))}
</div>
<div
aria-hidden
className="absolute bottom-8 top-8 z-10 w-px bg-critical/70"
style={{ left: x(now) + 16 }}
>
<span className="eyebrow absolute -top-4 left-1 text-critical">now</span>
</div>
<div className="space-y-4">
{[...byGame.entries()].map(([gameId, events]) => {
const game = gameMeta(gameId);
return (
<div key={gameId}>
<p className="eyebrow mb-1.5" style={{ color: game.hue }}>
{game.short}
</p>
<div className="relative h-auto space-y-1">
{events.map(({ event, clock }) => {
const left = x(clock.startsMs);
const unknownEnd = clock.endsMs === null;
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
const done = completions[event.id] !== undefined;
return (
<button
key={event.id}
type="button"
onClick={() => onOpen(event.id)}
title={event.title}
className={`relative flex h-6 items-center overflow-hidden rounded-[3px] px-1.5 text-left text-[0.6875rem] font-medium transition-opacity hover:opacity-100 ${
done ? "opacity-35" : "opacity-90"
}`}
style={{
marginLeft: left,
width: Math.max(right - left, 22),
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
borderLeft: `2px solid ${game.hue}`,
// A frayed right edge says the end is unannounced —
// visually distinct from an event ending far away.
maskImage: unknownEnd
? "linear-gradient(90deg, #000 60%, transparent 100%)"
: undefined,
color: "var(--color-ink)",
}}
>
<span className="truncate">{event.title}</span>
<span
aria-hidden
className="ml-auto size-1.5 shrink-0 rounded-full"
style={{ background: URGENCY_COLOR[clock.urgency] }}
/>
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
function monthBoundaries(min: number, max: number) {
const out: Array<{ ms: number; label: string }> = [];
const d = new Date(min);
d.setUTCDate(1);
d.setUTCHours(0, 0, 0, 0);
while (d.getTime() <= max) {
if (d.getTime() >= min) {
out.push({
ms: d.getTime(),
label: d.toLocaleDateString(undefined, { month: "short" }),
});
}
d.setUTCMonth(d.getUTCMonth() + 1);
}
return out;
}
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";
const root = document.getElementById("root");
if (root === null) throw new Error("#root is missing from index.html");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);