Files
gacha-event-tracker/src/client/components/EventDetail.tsx
T
Lucas WintherandClaude Opus 5 d433eeee50 Only claim a derived boundary is a source's when boundaryMs treats it that way
The day-precision note ("The source gave a date but no time of day, so this
counts down to that day's server reset...") rendered for any event with
endPrecision "day" and a stated end, with no check on where the date came
from. For a reader's own event that is false three times over: there is no
source (sourceUrl is null, sourceId is "you"), nobody gave a date — for a
repeating occurrence with no stated end the app derived it from the interval
— and it does not count down to a server reset, since boundaryMs only applies
that shift when extractionMethod === "parser". Gated the note on the same
condition so the copy and the countdown agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 05:04:39 +02:00

365 lines
13 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 { useEffect, useState } from "react";
import { useGameMeta } from "../state/gameMeta.tsx";
import {
isCustomEventId,
type CustomEvent,
type CustomGames,
type LaneId,
} from "../../shared/custom.ts";
import type { EventDraft } from "../state/useCustom.ts";
import { cadenceLabel, EventForm } from "./CustomForms.tsx";
import {
formatAbsolute,
formatRemaining,
REGION_LABEL,
} from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
import type { Status } from "../state/useProgress.ts";
import { ProgressControls } from "./ProgressControls.tsx";
import { DailyChecklist } from "./DailyChecklist.tsx";
import { dailyOverride } from "../../shared/daily.ts";
import type { Region } from "../../shared/schema.ts";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export function EventDetail({
row,
completed,
ignored,
status,
effort,
note,
region,
now,
daily,
detectedDaily,
dailyDays,
onDaily,
onToggleDay,
onIgnore,
onStatus,
onEffort,
onNote,
onClose,
own,
}: {
row: RowEvent;
completed: boolean;
ignored: boolean;
status: Status | undefined;
effort: Effort | undefined;
note: string;
region: Region;
now: number;
/** Whether to treat this as repeating, the reader's answer included. */
daily: boolean;
/** What the source's wording implies, so an override can fall back to it. */
detectedDaily: boolean;
/** Days already ticked off, for events that repeat. */
dailyDays: string[];
onDaily: (id: string, daily: boolean | undefined) => void;
onToggleDay: (id: string, day: string) => void;
onIgnore: (id: string) => void;
onStatus: (id: string, s: Status | undefined) => void;
onEffort: (id: string, e: Effort | undefined) => void;
onNote: (id: string, n: string) => void;
onClose: () => void;
/**
* Present only when this is an event the reader entered themselves, in which
* case they can change it or take it back — this sheet is where anyone would
* look for that, rather than a list in settings.
*/
own?:
| {
record: CustomEvent;
lanes: LaneId[];
games: CustomGames;
onSave: (id: string, draft: EventDraft) => void;
onDelete: (id: string) => void;
strandedBy: (draft: EventDraft) => number;
}
| undefined;
}) {
const gameMeta = useGameMeta();
const [editing, setEditing] = useState(false);
const { event, clock } = row;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
const risk = status === "done" ? "fine" : pressure(effort, clock.msRemaining);
// Read once rather than at both the guard and the render: calling it twice
// was what forced the non-null assertion below it.
const cadence = cadenceLabel(own?.record.repeat ?? null);
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-scrim 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>
{/* Stated plainly, next to the title rather than buried at the bottom:
a reader has to be able to tell which dates the app went and found
and which ones they typed in themselves. */}
{isCustomEventId(event.id) && (
<p className="mt-1 text-xs text-faint">
You added this. The dates are yours, not a source's.
</p>
)}
{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">
{/* Off the clock, not off the event: a day-precision boundary is
resolved to the game's reset there, and printing the stored
00:00Z instead would name a different day to the countdown. */}
<Field label="Starts">
{formatAbsolute(clock.startsMs, event.startPrecision === "exact")}
</Field>
<Field label="Ends">
{clock.endsMs === null ? (
<span className="text-faint">Not announced</span>
) : (
formatAbsolute(clock.endsMs, 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>
{cadence !== null && (
<p className="mt-2 text-xs text-faint">{cadence}</p>
)}
{risk !== "fine" && effort !== undefined && clock.msRemaining !== null && (
<p
className="mt-4 rounded-lg border px-3 py-2 text-xs leading-relaxed"
style={{
borderColor:
risk === "unlikely"
? "color-mix(in srgb, var(--color-critical) 40%, transparent)"
: "color-mix(in srgb, var(--color-soon) 40%, transparent)",
color:
risk === "unlikely" ? "var(--color-critical)" : "var(--color-soon)",
}}
>
{pressureReason(effort, clock.msRemaining)} It is a rough guide, not a
verdict — you know your own schedule.
</p>
)}
{/* A repeating event gets the checklist instead of nothing but a
"mark done" — its work is spread over every day of the run, and one
tick cannot express that.
Detection reads the source's wording and is wrong in both
directions, so the reader can say. The control sits where the
checklist goes, which is the one place the answer visibly matters. */}
{daily ? (
<>
<DailyChecklist
startsMs={clock.startsMs}
endsMs={clock.endsMs}
region={region}
game={event.game}
now={now}
logged={dailyDays}
onToggleDay={(day) => onToggleDay(event.id, day)}
/>
<button
type="button"
onClick={() => onDaily(event.id, dailyOverride(false, detectedDaily))}
className="mt-2 text-xs text-faint transition-colors hover:text-muted"
>
This isn't a daily event
</button>
</>
) : (
<button
type="button"
onClick={() => onDaily(event.id, dailyOverride(true, detectedDaily))}
className="mt-5 flex w-full items-center gap-2.5 rounded-xl border border-dashed border-hairline px-4 py-3 text-left transition-colors hover:border-faint"
>
<span aria-hidden className="text-base leading-none text-faint">+</span>
<span>
<span className="block text-sm font-medium">
It repeats daily
</span>
<span className="block text-xs leading-relaxed text-faint">
Track it day by day, and tick today off as you go.
</span>
</span>
</button>
)}
<ProgressControls
status={status}
effort={effort}
note={note}
onStatus={(s) => onStatus(event.id, s)}
onEffort={(e) => onEffort(event.id, e)}
onNote={(n) => onNote(event.id, n)}
/>
{own !== undefined && (
<div className="mt-4 border-t border-hairline pt-4">
{editing ? (
<EventForm
lanes={own.lanes}
customGames={own.games}
initial={own.record}
onSave={(draft) => {
own.onSave(event.id, draft);
setEditing(false);
}}
onCancel={() => setEditing(false)}
strandedBy={own.strandedBy}
/>
) : (
<div className="flex gap-2">
<button
type="button"
onClick={() => setEditing(true)}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Edit this event
</button>
<button
type="button"
onClick={() => {
own.onDelete(event.id);
onClose();
}}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-critical"
>
Delete
</button>
</div>
)}
{/* Their marks and logged days are not swept up with it: reaching
into three other stores on one tap is how a misclick costs
somebody a streak. */}
<p className="mt-2 text-xs leading-relaxed text-faint">
Deleting removes the event. Anything you ticked off stays.
</p>
</div>
)}
{/* Gated on the same condition `boundaryMs` uses to apply the reset
shift, so the copy never claims a countdown the clock is not
actually running. A reader's own event is day-precision exactly as
often as a parsed one, but `extractionMethod` is "manual" — there
is no source to have "given" a date, and `readerInstant` already
resolved it to the instant they meant rather than a placeholder
`boundaryMs` has to reinterpret. */}
{event.endPrecision === "day" &&
event.endsAt !== null &&
event.extractionMethod === "parser" && (
<p className="mt-3 text-xs leading-relaxed text-faint">
The source gave a date but no time of day, so this counts down to
that day's {REGION_LABEL[region]} server reset — where these usually land, but a
reading rather than a stated time. Check in-game before the last
hours.
</p>
)}
{/* Says what it does and does what it says.
It used to advance one step round the untouched → doing → done
cycle, so a reader pressing a button labelled "Mark done" on a fresh
event got "doing it" and had to press it again — and the second
press from "done" silently wiped the status rather than undoing
anything. Three states need three targets, which is what the control
above is; this one is the commit, so it goes straight to done and
back. */}
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={() => onStatus(event.id, completed ? undefined : "done")}
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-ink-strong"
}`}
>
{completed ? "Mark not done" : "Mark done"}
</button>
{/* Nothing to link to when the reader typed this themselves, and a
dead "Source" button would imply somebody else vouched for the
date. Provenance is stated above instead. */}
{event.sourceUrl !== null && (
<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>
{/* Ignoring is not completing. "Done" keeps an event visible and
counted; "not interested" removes it from both views entirely. */}
<button
type="button"
onClick={() => {
onIgnore(event.id);
if (!ignored) onClose();
}}
className="mt-3 w-full rounded-lg px-4 py-2 text-xs text-faint transition-colors hover:text-muted"
>
{ignored
? "Stop ignoring this event"
: "Not interested — hide this event"}
</button>
</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>
);
}