Files
gacha-event-tracker/src/client/components/redesign/RoutineHistory.tsx
T
Lucas WintherandClaude Opus 5.5 50c6e91b00 feat: the redesigned Event Clock
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 <noreply@anthropic.com>
2026-10-18 01:55:16 +02:00

147 lines
5.4 KiB
TypeScript

import type { ReaderState, Account } from "../../state/reader/schema.ts";
import { recordOf, actionMeta } from "../../state/reader/actions.ts";
import {
clockFromRevision,
readerDayKey,
resolveReaderClock,
} from "../../../shared/readerClock.ts";
import { catchUpDays } from "../../../shared/daily.ts";
import { routinesFor } from "../../../shared/routines.ts";
import { TaskSchema } from "../../../shared/task.ts";
import { formatInZone } from "../../state/displayTime.ts";
import type { ActionHandler } from "./GameSettings.tsx";
export function RoutineHistory({
account,
state,
now,
onAction,
}: {
account: Account;
state: ReaderState;
now: number;
onAction: ActionHandler;
}) {
const data = state.data[account.id]!;
const current = resolveReaderClock(account, now);
const revisions = account.clocks
.slice()
.sort((a, b) => a.effectiveAt.localeCompare(b.effectiveAt));
return (
<details className="routine-history">
<summary>Catch up & recorded history</summary>
{revisions.map((revision, index) => {
const clock = clockFromRevision(revision),
last =
index === revisions.length - 1
? now
: Math.min(
now,
Date.parse(revisions[index + 1]!.effectiveAt) - 1,
),
today = readerDayKey(last, clock);
const items = {
...data.daily[revision.id],
...data.amounts[revision.id],
};
for (const [id, p] of Object.entries(account.routines))
if (p.followed && revision.id === current.revision) items[id] ??= {};
return (
<section key={revision.id}>
<h3>
{revision.id === current.revision
? "Current reset timing"
: "Before reset timing changed"}
</h3>
<p className="help">
{revision.region} ·{" "}
{revision.reset
? `${revision.reset.hour}:${String(revision.reset.minute).padStart(2, "0")}`
: "Automatic"}{" "}
·{" "}
{revision.effectiveAt.startsWith("1970")
? "Migrated history"
: formatInZone(revision.effectiveAt, true, null)}
</p>
{Object.keys(items).map((id) => {
const title = id.startsWith("dailies:")
? "Daily check-in"
: (routinesFor(account.game).find((r) => r.id === id)?.title ??
String(
recordOf(state.tasks[id]).title ??
Object.values(state.seenEvents).find(
(s) => s.event.id === id,
)?.event.title ??
id,
));
// A routine with its own reset hour keeps its own day labels,
// as its row does (state/reader/routines.ts).
const own = TaskSchema.safeParse(state.tasks[id]),
hour = own.success ? own.data.resetHour : undefined,
itemClock =
hour === undefined
? clock
: { ...clock, resetHour: hour, resetMinute: 0 },
itemToday =
hour === undefined ? today : readerDayKey(last, itemClock);
const raw = recordOf(data.daily[revision.id]?.[id]),
days = new Set(
Array.isArray(raw.days)
? raw.days.filter((d): d is string => typeof d === "string")
: [],
);
const recent = catchUpDays(
last,
revision.region,
account.game,
revision.effectiveAt.startsWith("1970")
? null
: Date.parse(revision.effectiveAt),
14,
itemClock,
);
return (
<div key={id}>
<p>{title}</p>
{!data.amounts[revision.id]?.[id] && (
<div className="history-days">
{[...new Set([...recent, ...days])].sort().map((day) => (
<button
type="button"
key={day}
disabled={day > itemToday}
aria-pressed={days.has(day)}
onClick={() =>
void onAction({
type: "day",
accountId: account.id,
revisionId: revision.id,
itemId: id,
day,
checked: !days.has(day),
...actionMeta(),
})
}
>
{day}
</button>
))}
</div>
)}
{Object.entries(
recordOf(data.amounts[revision.id]?.[id]),
).map(([period, entry]) => (
<p className="help" key={period}>
{period}:{" "}
{String(recordOf(entry).value ?? "unreadable amount")}
</p>
))}
</div>
);
})}
</section>
);
})}
</details>
);
}