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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddd9673f86
commit
50c6e91b00
+2
-1258
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -1,3 +1,4 @@
|
||||
import { CodesFeedSchema, type CodesFeed } from "../shared/codes.ts";
|
||||
import { EventFeed, SCHEMA_VERSION } from "../shared/feed.ts";
|
||||
|
||||
export type FeedState =
|
||||
@@ -50,7 +51,7 @@ export async function fetchFeed(
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export type RefreshOutcome = "updated" | "same" | "failed";
|
||||
export type RefreshOutcome = "updated" | "same" | "failed" | "partial";
|
||||
|
||||
/**
|
||||
* What a "check for new data" brought back.
|
||||
@@ -67,3 +68,11 @@ export function refreshOutcome(before: EventFeed, after: EventFeed | Error): Ref
|
||||
JSON.stringify(before.sources) === JSON.stringify(after.sources);
|
||||
return same ? "same" : "updated";
|
||||
}
|
||||
|
||||
export async function fetchCodesFeed(signal?: AbortSignal, options: {fresh?: boolean} = {}): Promise<CodesFeed> {
|
||||
const response = await fetch(new URL("data/codes.v1.json", document.baseURI), { signal: signal ?? null, ...(options.fresh ? {cache: "no-cache" as const} : {}) });
|
||||
if (!response.ok || (options.fresh && response.headers.get("x-served-from") === "sw-cache")) throw new Error("The codes feed is unavailable.");
|
||||
const parsed = CodesFeedSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new Error("The codes feed could not be read.");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
@@ -39,23 +39,20 @@ export function ChangelogSheet({
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const loose = entries.filter((e) => e.note === undefined);
|
||||
const months = [...new Set(loose.map((e) => e.date.slice(0, 7)))];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close changelog"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-scrim backdrop-blur-sm"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Changelog"
|
||||
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"
|
||||
>
|
||||
<button type="button" aria-label="Close changelog" onClick={onClose} className="absolute inset-0 bg-scrim backdrop-blur-sm" />
|
||||
<div role="dialog" aria-modal="true" aria-label="Changelog" 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">
|
||||
<ChangelogContent notes={notes} entries={entries} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogContent({ notes, entries }: { notes: readonly Note[]; entries: readonly Entry[] }) {
|
||||
const loose = entries.filter((e) => e.note === undefined);
|
||||
const months = [...new Set(loose.map((e) => e.date.slice(0, 7)))];
|
||||
return (<>
|
||||
<h2 className="font-display text-xl font-semibold">Changelog</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-faint">
|
||||
What changed, summarised from the project's history and design notes. Each links to the
|
||||
@@ -122,9 +119,7 @@ export function ChangelogSheet({
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</>);
|
||||
}
|
||||
|
||||
function EntryLine({ entry }: { entry: Entry }) {
|
||||
|
||||
@@ -131,7 +131,7 @@ const SITES: Record<string, { name: string; url: string }> = {
|
||||
"endfield.wiki.gg": { name: "wiki.gg", url: "https://wiki.gg" },
|
||||
};
|
||||
|
||||
function siteFor(url: string): { name: string; url: string } {
|
||||
export function siteFor(url: string): { name: string; url: string } {
|
||||
try {
|
||||
const host = new URL(url).host;
|
||||
return SITES[host] ?? { name: host, url: `https://${host}` };
|
||||
@@ -570,6 +570,7 @@ export function Colophon({
|
||||
}
|
||||
|
||||
const OUTCOME: Record<RefreshOutcome, string> = {
|
||||
partial: "Event data checked; redeem codes could not be checked.",
|
||||
updated: "Updated — newer data is on screen.",
|
||||
same: "Nothing newer published yet. Sources are read about twice a day.",
|
||||
failed: "Couldn't reach the server, so this is still the copy stored here.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import type { LaneId } from "../../shared/custom.ts";
|
||||
import type { Region } from "../../shared/schema.ts";
|
||||
import { REGION_LABEL } from "../../shared/time.ts";
|
||||
@@ -11,6 +11,9 @@ import { isCustomGameId } from "../../shared/custom.ts";
|
||||
import { exceptionLine, setFor, shownFor, type PerGame } from "../../shared/perGame.ts";
|
||||
import { asOfLabel, offered, routinesFor } from "../../shared/routines.ts";
|
||||
|
||||
const SettingsFilter = createContext<string | undefined>(undefined);
|
||||
const GROUP_PAGE: Record<string, string> = { Games: "games", "Server region": "time", Appearance: "appearance", "What you see": "lists", "Your own games and events": "general", "Your progress": "backup" };
|
||||
|
||||
const REGIONS: Array<{ id: Region; label: string }> = (
|
||||
["america", "europe", "asia"] as const
|
||||
).map((id) => ({ id, label: REGION_LABEL[id] }));
|
||||
@@ -81,7 +84,9 @@ export function Controls({
|
||||
onExportAll,
|
||||
onImportAll,
|
||||
own,
|
||||
section,
|
||||
}: {
|
||||
section?: string | undefined;
|
||||
games: LaneId[];
|
||||
prefs: Prefs;
|
||||
onToggleGame: (g: LaneId) => void;
|
||||
@@ -121,7 +126,7 @@ export function Controls({
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
<SettingsFilter value={section}><section
|
||||
aria-labelledby="settings-heading"
|
||||
className="border-t border-hairline px-4 py-5"
|
||||
>
|
||||
@@ -188,6 +193,10 @@ export function Controls({
|
||||
{/* Genuinely only how the page looks, and instant: no reload, and
|
||||
nothing marked, typed or ticked is touched. */}
|
||||
<Group name="Appearance" state={themeLabel(prefs.theme)}>
|
||||
<div className="appearance-choices">
|
||||
<label>Interface<select aria-label="Interface" value={prefs.interface ?? "relaxed"} onChange={(e) => onUpdate({ interface: e.target.value as "relaxed" | "structured" })}><option value="relaxed">Relaxed — more breathing room</option><option value="structured">Structured — a ruled planner</option></select></label>
|
||||
<label>Event rows<select aria-label="Event rows" value={prefs.density ?? "standard"} onChange={(e) => onUpdate({ density: e.target.value as "standard" | "compact" })}><option value="standard">Standard</option><option value="compact">Compact</option></select></label>
|
||||
</div>
|
||||
<PillGroup
|
||||
label="Appearance"
|
||||
labelHidden
|
||||
@@ -432,7 +441,7 @@ export function Controls({
|
||||
</p>
|
||||
</Group>
|
||||
</div>
|
||||
</section>
|
||||
</section></SettingsFilter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -614,8 +623,10 @@ function Group({
|
||||
state: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const section = useContext(SettingsFilter);
|
||||
if (section !== undefined && GROUP_PAGE[name] !== section) return null;
|
||||
return (
|
||||
<details className="settings-group border-b border-hairline">
|
||||
<details open={section !== undefined} className="settings-group border-b border-hairline">
|
||||
<summary className="flex items-center gap-2.5 py-3 text-sm">
|
||||
<svg
|
||||
aria-hidden
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { DisplayTimeContext } from "../state/displayTime.ts";
|
||||
import { useContext, useState } from "react";
|
||||
import {
|
||||
isCustomGameId,
|
||||
type CustomEvent,
|
||||
@@ -351,9 +352,13 @@ export function GameForm({
|
||||
}
|
||||
|
||||
/** Split a stored instant back into the date and time a form field wants. */
|
||||
function fields(iso: string | null): { date: string; time: string } {
|
||||
function fields(iso: string | null, timezone?: string | null): { date: string; time: string } {
|
||||
if (iso === null) return { date: "", time: "" };
|
||||
const d = new Date(iso);
|
||||
if (timezone) {
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year:"numeric", month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", hourCycle:"h23" }).formatToParts(d).map(p => [p.type, p.value]));
|
||||
return { date: `${parts.year}-${parts.month}-${parts.day}`, time: `${parts.hour}:${parts.minute}` };
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return {
|
||||
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
|
||||
@@ -475,7 +480,7 @@ export function eventStillNeeded(s: {
|
||||
* can be proved to complete into a saveable event without a click, and so the
|
||||
* still-needed line and Save's disabled state read the same answer.
|
||||
*/
|
||||
export function eventFormState(v: EventFormValues, existingUntil: string | null) {
|
||||
export function eventFormState(v: EventFormValues, existingUntil: string | null, timezone?: string | null) {
|
||||
// A preset's period is its window, so it has no end to state. The end the
|
||||
// reader may have typed under a different cadence is kept in state rather
|
||||
// than cleared — hiding a field and quietly discarding what is in it is how
|
||||
@@ -483,11 +488,11 @@ export function eventFormState(v: EventFormValues, existingUntil: string | null)
|
||||
const preset = v.cadence === "daily" || v.cadence === "weekly" || v.cadence === "monthly";
|
||||
const datedWindow = v.cadence === "one-off" || v.cadence === "custom";
|
||||
|
||||
const startsAt = v.startDate === "" ? null : readerInstant(v.startDate, v.startTime, "start");
|
||||
const startsAt = v.startDate === "" ? null : readerInstant(v.startDate, v.startTime, "start", timezone);
|
||||
const endsAt =
|
||||
!datedWindow || !v.endKnown || v.endDate === ""
|
||||
? null
|
||||
: readerInstant(v.endDate, v.endTime, "end");
|
||||
: readerInstant(v.endDate, v.endTime, "end", timezone);
|
||||
|
||||
const endMissing = datedWindow && v.endKnown && v.endDate !== "" && endsAt === null;
|
||||
const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt;
|
||||
@@ -625,6 +630,7 @@ export function EventForm({
|
||||
strandedBy?: ((draft: EventDraft) => number) | undefined;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
const timezone = useContext(DisplayTimeContext);
|
||||
|
||||
// The reader's own games first, and so the default too. Someone filling this
|
||||
// in by hand is usually doing it *because* the game isn't tracked; making
|
||||
@@ -635,8 +641,8 @@ export function EventForm({
|
||||
);
|
||||
|
||||
const [v, setV] = useState<EventFormValues>(() => {
|
||||
const start = fields(initial?.startsAt ?? null);
|
||||
const end = fields(initial?.endsAt ?? null);
|
||||
const start = fields(initial?.startsAt ?? null, timezone);
|
||||
const end = fields(initial?.endsAt ?? null, timezone);
|
||||
// Round-tripped through the same fields-then-readerInstant path the live
|
||||
// form uses, rather than read straight off the record. The two disagree: a
|
||||
// day-precision end is stored as whatever instant it was written at, and
|
||||
@@ -647,11 +653,11 @@ export function EventForm({
|
||||
const startTime = initial?.startPrecision === "exact" ? start.time : "";
|
||||
const endTime = initial?.endPrecision === "exact" ? end.time : "";
|
||||
const initialStartsAt =
|
||||
start.date === "" ? null : (readerInstant(start.date, startTime, "start") ?? null);
|
||||
start.date === "" ? null : (readerInstant(start.date, startTime, "start", timezone) ?? null);
|
||||
const initialEndsAt =
|
||||
initial?.endsAt == null || end.date === ""
|
||||
? null
|
||||
: (readerInstant(end.date, endTime, "end") ?? null);
|
||||
: (readerInstant(end.date, endTime, "end", timezone) ?? null);
|
||||
const opening = openingControls(
|
||||
initialStartsAt,
|
||||
initialEndsAt,
|
||||
@@ -697,7 +703,7 @@ export function EventForm({
|
||||
const set = (patch: Partial<EventFormValues>) => setV((prev) => ({ ...prev, ...patch }));
|
||||
const [chosen, setChosen] = useState<EventTemplate | null>(null);
|
||||
|
||||
const s = eventFormState(v, initial?.repeat?.until ?? null);
|
||||
const s = eventFormState(v, initial?.repeat?.until ?? null, timezone);
|
||||
const { preset, datedWindow, endMs, measured, measuring, repeat, draft } = s;
|
||||
|
||||
// Only a schedule change re-keys anything. Renaming does not — the token is
|
||||
@@ -735,6 +741,7 @@ export function EventForm({
|
||||
onSave(draft);
|
||||
}}
|
||||
>
|
||||
<p className="help">Dates and times use {timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}.</p>
|
||||
{initial === undefined && (
|
||||
<ExampleRow
|
||||
examples={EVENT_TEMPLATES}
|
||||
|
||||
@@ -227,7 +227,8 @@ export function tasksShown<T extends Pick<StripTask, "period">>(
|
||||
shown: { weekly: boolean; monthly: boolean; cycle: boolean },
|
||||
): T[] {
|
||||
const on = { week: shown.weekly, month: shown.monthly, cycle: shown.cycle } as const;
|
||||
return tasks.filter((t) => on[t.period.unit]);
|
||||
// A reader's own daily sits with the standing dailies, not these strips.
|
||||
return tasks.filter((t) => t.period.unit !== "day" && on[t.period.unit]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ResetTiming } from "../../shared/time.ts";
|
||||
import {
|
||||
CATCH_UP_DAYS,
|
||||
catchUpDays,
|
||||
@@ -33,7 +34,9 @@ export function DailyChecklist({
|
||||
logged,
|
||||
onToggleDay,
|
||||
goal,
|
||||
timing,
|
||||
}: {
|
||||
timing?: ResetTiming | undefined;
|
||||
startsMs: number;
|
||||
endsMs: number | null;
|
||||
region: Region;
|
||||
@@ -49,7 +52,7 @@ export function DailyChecklist({
|
||||
*/
|
||||
goal?: GoalStatus | undefined;
|
||||
}) {
|
||||
const summary = dailySummary({ startsMs, endsMs, region, game, now, logged });
|
||||
const summary = dailySummary({ startsMs, endsMs, region, game, now, logged, ...(timing === undefined ? {} : { timing }) });
|
||||
const { days, today, doneToday, todayInWindow } = summary;
|
||||
const started = now >= startsMs;
|
||||
|
||||
@@ -58,7 +61,7 @@ export function DailyChecklist({
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="eyebrow">Daily checklist</p>
|
||||
<p className="tnum text-[0.6875rem] text-faint">
|
||||
resets in {formatRemaining(msUntilReset(now, region, game))}
|
||||
resets in {formatRemaining(msUntilReset(now, region, game, timing))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +116,7 @@ export function DailyChecklist({
|
||||
yesterday could never say so. Only the past, and only as far back
|
||||
as `catchUpDays` goes. */}
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{catchUpDays(now, region, game, startsMs).map((day) => (
|
||||
{catchUpDays(now, region, game, startsMs, undefined, timing).map((day) => (
|
||||
<DayPip
|
||||
key={day}
|
||||
day={day}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ResetTiming } from "../../shared/time.ts";
|
||||
import { formatInZone } from "../state/displayTime.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||
import {
|
||||
@@ -9,7 +11,7 @@ import {
|
||||
import type { EventDraft } from "../state/useCustom.ts";
|
||||
import { cadenceLabel, EventForm } from "./CustomForms.tsx";
|
||||
import {
|
||||
formatAbsolute,
|
||||
formatAbsolute as defaultFormatAbsolute,
|
||||
formatRemaining,
|
||||
REGION_LABEL,
|
||||
} from "../../shared/time.ts";
|
||||
@@ -51,7 +53,13 @@ export function EventDetail({
|
||||
onNote,
|
||||
onClose,
|
||||
own,
|
||||
timing,
|
||||
timezone,
|
||||
sourceNotice,
|
||||
}: {
|
||||
sourceNotice?: React.ReactNode;
|
||||
timing?: ResetTiming | undefined;
|
||||
timezone?: string | null | undefined;
|
||||
row: RowEvent;
|
||||
completed: boolean;
|
||||
ignored: boolean;
|
||||
@@ -101,12 +109,13 @@ export function EventDetail({
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const formatAbsolute = (instant: string | number, withTime: boolean) => timezone === undefined ? defaultFormatAbsolute(instant, withTime) : formatInZone(instant, withTime, timezone);
|
||||
const gameMeta = useGameMeta();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const { event, clock } = row;
|
||||
// The run's claimable days caps an event-wide goal; null when the end is
|
||||
// unannounced, and then nothing is invented to cap it with.
|
||||
const run = runDays(clock.startsMs, clock.endsMs, region, event.game);
|
||||
const run = runDays(clock.startsMs, clock.endsMs, region, event.game, timing);
|
||||
const goalState =
|
||||
goal === undefined
|
||||
? undefined
|
||||
@@ -118,6 +127,7 @@ export function EventDetail({
|
||||
game: event.game,
|
||||
now,
|
||||
logged: dailyDays,
|
||||
...(timing === undefined ? {} : { timing }),
|
||||
amounts,
|
||||
});
|
||||
const game = gameMeta(event.game);
|
||||
@@ -136,25 +146,26 @@ export function EventDetail({
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
<div className="event-detail-overlay fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close details"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-scrim backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-scrim"
|
||||
/>
|
||||
<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"
|
||||
className="event-detail-sheet"
|
||||
>
|
||||
<p className="eyebrow" style={{ color: game.hue }}>
|
||||
<p className="detail-game" style={{ color: game.hue }}>
|
||||
{game.name}
|
||||
</p>
|
||||
<h2 className="mt-1.5 font-display text-xl font-semibold leading-snug">
|
||||
<h2 className="detail-title">
|
||||
{event.title}
|
||||
</h2>
|
||||
{sourceNotice}
|
||||
{/* 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. */}
|
||||
@@ -176,7 +187,7 @@ export function EventDetail({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<dl className="detail-facts">
|
||||
{/* 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. */}
|
||||
@@ -231,6 +242,7 @@ export function EventDetail({
|
||||
{daily ? (
|
||||
<>
|
||||
<DailyChecklist
|
||||
timing={timing}
|
||||
startsMs={clock.startsMs}
|
||||
endsMs={clock.endsMs}
|
||||
region={region}
|
||||
@@ -242,7 +254,7 @@ export function EventDetail({
|
||||
/>
|
||||
<GoalControl
|
||||
goal={goal}
|
||||
defaultWeekStartsOn={isoWeekday(dayKey(clock.startsMs, region, event.game))}
|
||||
defaultWeekStartsOn={isoWeekday(dayKey(clock.startsMs, region, event.game, timing))}
|
||||
maxDays={run === null ? null : run.length}
|
||||
onChange={(g) => onGoal(event.id, g)}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { Status } from "../state/useProgress.ts";
|
||||
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||
|
||||
export interface RowEvent {
|
||||
accountId?: string;
|
||||
region?: import("../../shared/schema.ts").Region;
|
||||
event: DisplayEvent;
|
||||
clock: EventClock;
|
||||
}
|
||||
@@ -22,6 +24,7 @@ export interface DailyBadge {
|
||||
}
|
||||
|
||||
interface EventRowProps {
|
||||
as?: "li" | "div";
|
||||
row: RowEvent;
|
||||
/**
|
||||
* The instant the page is being read at.
|
||||
@@ -47,6 +50,7 @@ interface EventRowProps {
|
||||
|
||||
export function EventRow({
|
||||
row,
|
||||
as: Container = "li",
|
||||
now,
|
||||
completed,
|
||||
status,
|
||||
@@ -73,7 +77,7 @@ export function EventRow({
|
||||
: formatRemaining(clock.msRemaining);
|
||||
|
||||
return (
|
||||
<li
|
||||
<Container
|
||||
className={`event-row relative border-b border-hairline/70 ${
|
||||
completed ? "is-complete" : ""
|
||||
}`}
|
||||
@@ -270,6 +274,6 @@ export function EventRow({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,25 +32,55 @@ const COLORS = [
|
||||
"var(--color-ink)",
|
||||
];
|
||||
|
||||
export function Fireworks() {
|
||||
/**
|
||||
* The grand version, for finishing a whole section (every daily, or every
|
||||
* routine in a game's group) rather than one item: more bursts across the
|
||||
* section, wider and longer, in waves. Same fixed geometry rule as above.
|
||||
*/
|
||||
const GRAND_BURSTS: Array<{ x: number; y: number; delay: number }> = [
|
||||
{ x: 12, y: 30, delay: 0 },
|
||||
{ x: 50, y: 18, delay: 120 },
|
||||
{ x: 86, y: 34, delay: 220 },
|
||||
{ x: 30, y: 62, delay: 380 },
|
||||
{ x: 70, y: 66, delay: 480 },
|
||||
{ x: 20, y: 86, delay: 640 },
|
||||
{ x: 80, y: 84, delay: 720 },
|
||||
];
|
||||
const GRAND_SPARKS = 14;
|
||||
|
||||
export function Fireworks({
|
||||
grand = false,
|
||||
hue,
|
||||
}: {
|
||||
/** The section-complete celebration rather than the single burst. */
|
||||
grand?: boolean;
|
||||
/** A game's colour to mix into the sparks. */
|
||||
hue?: string | undefined;
|
||||
} = {}) {
|
||||
const bursts = grand ? GRAND_BURSTS : BURSTS,
|
||||
sparks = grand ? GRAND_SPARKS : SPARKS,
|
||||
colors = hue ? [hue, ...COLORS] : COLORS;
|
||||
return (
|
||||
<div aria-hidden className="fireworks">
|
||||
{BURSTS.map((burst, b) => (
|
||||
<div aria-hidden className={`fireworks${grand ? " grand" : ""}`}>
|
||||
{bursts.map((burst, b) => (
|
||||
<span
|
||||
key={b}
|
||||
className="firework"
|
||||
style={{ left: `${burst.x}%`, top: `${burst.y}%` }}
|
||||
>
|
||||
{Array.from({ length: SPARKS }, (_, i) => (
|
||||
{Array.from({ length: sparks }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="spark"
|
||||
style={{
|
||||
["--a" as string]: `${(360 / SPARKS) * i + b * 13}deg`,
|
||||
["--a" as string]: `${(360 / sparks) * i + b * 13}deg`,
|
||||
// Uneven reach, so it reads as a burst rather than a wheel.
|
||||
["--d" as string]: `${16 + ((i * 7) % 13)}px`,
|
||||
["--d" as string]: grand
|
||||
? `${34 + ((i * 11) % 34)}px`
|
||||
: `${16 + ((i * 7) % 13)}px`,
|
||||
["--delay" as string]: `${burst.delay}ms`,
|
||||
background: COLORS[(i + b) % COLORS.length] ?? "var(--color-ink)",
|
||||
background:
|
||||
colors[(i + b) % colors.length] ?? "var(--color-ink)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Urgency } from "../../shared/time.ts";
|
||||
import { URGENCY_COLOR } from "./Meter.tsx";
|
||||
|
||||
const STEPS: Array<{ urgency: Urgency; label: string }> = [
|
||||
export const STEPS: Array<{ urgency: Urgency; label: string }> = [
|
||||
{ urgency: "calm", label: "a week+" },
|
||||
{ urgency: "near", label: "under a week" },
|
||||
{ urgency: "soon", label: "under 3 days" },
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { Urgency } from "../../shared/time.ts";
|
||||
|
||||
const TICKS = 24;
|
||||
|
||||
// `--color-urgency-*` is set by the redesign's appearance (the live ramp,
|
||||
// kept apart from its accent); the live site doesn't set it and falls back.
|
||||
export const URGENCY_COLOR: Record<Urgency, string> = {
|
||||
expired: "var(--color-faint)",
|
||||
critical: "var(--color-critical)",
|
||||
soon: "var(--color-soon)",
|
||||
near: "var(--color-near)",
|
||||
calm: "var(--color-calm)",
|
||||
critical: "var(--color-urgency-critical, var(--color-critical))",
|
||||
soon: "var(--color-urgency-soon, var(--color-soon))",
|
||||
near: "var(--color-urgency-near, var(--color-near))",
|
||||
calm: "var(--color-urgency-calm, var(--color-calm))",
|
||||
};
|
||||
|
||||
interface MeterProps {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { REGION_LABEL } from "../../shared/time.ts";
|
||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||
import { formatRemaining } from "../../shared/time.ts";
|
||||
import type { RowEvent } from "./EventRow.tsx";
|
||||
@@ -17,6 +18,7 @@ export function NextUp({
|
||||
rows,
|
||||
focused,
|
||||
onOpen,
|
||||
onOpenRow,
|
||||
}: {
|
||||
/**
|
||||
* The soonest-expiring events the reader has neither finished nor ignored,
|
||||
@@ -27,6 +29,7 @@ export function NextUp({
|
||||
rows: RowEvent[];
|
||||
/** Name of the game being focused on, when the page is narrowed to one. */
|
||||
focused: string | null;
|
||||
onOpenRow?: ((row: RowEvent) => void) | undefined;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
@@ -69,14 +72,14 @@ export function NextUp({
|
||||
inert and read as a heading rather than a way in. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(event.id)}
|
||||
onClick={() => onOpenRow ? onOpenRow(rows[0]!) : onOpen(event.id)}
|
||||
className="group mt-2 block max-w-full text-left"
|
||||
>
|
||||
<h1 className="font-display text-[1.75rem] font-semibold leading-[1.15] tracking-tight transition-colors duration-150 group-hover:text-ink-strong">
|
||||
{event.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm" style={{ color: game.hue }}>
|
||||
{game.name}
|
||||
{game.name}{rows[0]?.region ? ` · ${REGION_LABEL[rows[0].region]}` : ""}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -107,7 +110,7 @@ export function NextUp({
|
||||
<p className="eyebrow">Then</p>
|
||||
<ul className="mt-1.5">
|
||||
{rest.map((row) => (
|
||||
<QueuedRow key={row.event.id} row={row} onOpen={onOpen} />
|
||||
<QueuedRow key={(row.accountId ?? "") + row.event.id} row={row} onOpen={id => onOpenRow ? onOpenRow(row) : onOpen(id)} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function taskFieldsFrom(initial: TaskDraft | undefined, lanes: readonly L
|
||||
return {
|
||||
game: initial?.game ?? lanes[0] ?? "",
|
||||
title: initial?.title ?? "",
|
||||
unit: p?.unit ?? "week",
|
||||
unit: p === undefined || p.unit === "day" ? "week" : p.unit,
|
||||
weekday: p?.unit === "week" ? p.startsOn : 1,
|
||||
monthDay: String(p?.unit === "month" ? p.startsOn : 1),
|
||||
cycleDays: String(p?.unit === "cycle" ? p.days : 14),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useLayoutEffect, useRef } from "react";
|
||||
import { Fragment, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||
import type { LaneId } from "../../shared/custom.ts";
|
||||
import { DAY, endingSoonestFirst } from "../../shared/time.ts";
|
||||
@@ -20,12 +20,6 @@ import { canStep, stepDayWidth, weekLabelStep } from "../state/zoom.ts";
|
||||
*/
|
||||
const PIN = 8;
|
||||
|
||||
/**
|
||||
* A sliver of time before now, so the "now" rule reads as a line in the view
|
||||
* rather than merging with the border.
|
||||
*/
|
||||
const HALF_DAY_LEAD = 12 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How far back the view can be scrolled beyond the oldest running event, so
|
||||
* "when did this start?" is answerable without the window being unbounded.
|
||||
@@ -47,7 +41,7 @@ const PAST_LEAD = 7 * DAY;
|
||||
*/
|
||||
const PAST_LIMIT = 60 * DAY;
|
||||
|
||||
/** Where the now rule sits when the board opens: a little in from the edge. */
|
||||
/** Room left of the earliest start the board opens on, so its edge is seen. */
|
||||
const OPEN_INSET = 28;
|
||||
|
||||
/**
|
||||
@@ -116,8 +110,16 @@ export function Timeline({
|
||||
splitUpcoming,
|
||||
gameOrder,
|
||||
onOpen,
|
||||
onOpenRow,
|
||||
isDoneRow,
|
||||
isDone,
|
||||
expand,
|
||||
routines = [],
|
||||
routinesInline = false,
|
||||
label,
|
||||
controls,
|
||||
afterHeader,
|
||||
servers = () => true,
|
||||
}: {
|
||||
rows: RowEvent[];
|
||||
now: number;
|
||||
@@ -161,6 +163,8 @@ export function Timeline({
|
||||
*/
|
||||
gameOrder?: readonly LaneId[];
|
||||
onOpen: (id: string) => void;
|
||||
onOpenRow?: (row: RowEvent) => void;
|
||||
isDoneRow?: (row: RowEvent) => boolean;
|
||||
/**
|
||||
* Asked rather than derived from the progress store: an entry exists there
|
||||
* the moment a reader records an effort or a note, and dimming a bar for
|
||||
@@ -179,6 +183,21 @@ export function Timeline({
|
||||
* terminate.
|
||||
*/
|
||||
expand?: ((minMs: number, maxMs: number) => RowEvent[]) | undefined;
|
||||
/**
|
||||
* Routine periods drawn in their own section under the event lanes, behind a
|
||||
* divider. They never move the board's window: a period is clamped to it.
|
||||
*/
|
||||
routines?: readonly ClassicRoutine[];
|
||||
/** Routines at the end of each game's lane instead of their own section. */
|
||||
routinesInline?: boolean;
|
||||
/** Short text at a bar's right end, such as time left or the end date. */
|
||||
label?: (row: RowEvent) => string | null;
|
||||
/** More pill toggles for the board's header, after the stacking pills. */
|
||||
controls?: ReactNode;
|
||||
/** Drawn between the header and the board, such as the focus bar. */
|
||||
afterHeader?: ReactNode;
|
||||
/** Whether a game is on several servers, so its bars name the server. */
|
||||
servers?: (game: string) => boolean;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
@@ -218,9 +237,23 @@ export function Timeline({
|
||||
// still on screen — an event that began three days ago is context, not
|
||||
// history. Keyed on the rounded offset so it runs when the range changes,
|
||||
// not every second: re-scrolling on each tick would fight the reader.
|
||||
const openAt = Math.round(Math.max(0, x(now - HALF_DAY_LEAD) - OPEN_INSET));
|
||||
// Opens far enough back that most running bars show their start, and so
|
||||
// their names: back to the median running start, keeping now between a
|
||||
// quarter and half of the way across.
|
||||
const liveStarts = plotted
|
||||
.filter((r) => r.clock.live)
|
||||
.map((r) => x(r.clock.startsMs))
|
||||
.sort((a, b) => a - b);
|
||||
const nowX = x(now);
|
||||
const medianStart = liveStarts[Math.floor(liveStarts.length / 2)] ?? nowX;
|
||||
const openFor = (viewport: number) =>
|
||||
Math.round(Math.max(0, openingLeft(nowX, medianStart, viewport)));
|
||||
const openAt = `${Math.round(nowX)}:${Math.round(medianStart)}`;
|
||||
const jumpToNow = (behavior: ScrollBehavior) =>
|
||||
scroller.current?.scrollTo({ left: openAt, behavior });
|
||||
scroller.current?.scrollTo({
|
||||
left: openFor(scroller.current.clientWidth),
|
||||
behavior,
|
||||
});
|
||||
|
||||
/**
|
||||
* A moment in time to hold still through the next re-render, and where in the
|
||||
@@ -265,16 +298,28 @@ export function Timeline({
|
||||
// Open at today rather than at the far past: it is what they came for.
|
||||
// Keyed on the rounded offset so it runs when the range changes, not every
|
||||
// second — re-scrolling on each tick would fight the reader's own scrolling.
|
||||
el.scrollTo({ left: openAt, behavior: "instant" });
|
||||
el.scrollTo({ left: openFor(el.clientWidth), behavior: "instant" });
|
||||
trackScroll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openAt]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (rows.length === 0 && routines.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>
|
||||
<>
|
||||
<BoardHeader
|
||||
group={group}
|
||||
onGroup={onGroup}
|
||||
controls={controls}
|
||||
canOut={canStep(dayWidth, -1)}
|
||||
canIn={canStep(dayWidth, 1)}
|
||||
onZoom={zoom}
|
||||
onToday={() => jumpToNow("smooth")}
|
||||
/>
|
||||
{afterHeader}
|
||||
<p className="px-4 py-10 text-sm text-muted">
|
||||
Nothing to plot. Switch a game back on to see its schedule.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +329,49 @@ export function Timeline({
|
||||
// under-counts what is actually drawn there.
|
||||
const marks = startMarkers(drawn, x);
|
||||
|
||||
const routineBar = (r: ClassicRoutine) => {
|
||||
const game = gameMeta(r.game);
|
||||
const left = Math.max(x(r.start), 0);
|
||||
const width = Math.max(Math.min(x(r.end), chartWidth) - left, MIN_BAR);
|
||||
return (
|
||||
<button
|
||||
key={r.key}
|
||||
type="button"
|
||||
onClick={r.onOpen}
|
||||
title={`${r.title} · ${r.detail}`}
|
||||
className={`relative flex h-9 items-center gap-2 rounded-[5px] px-3 text-left text-[0.75rem] font-medium transition-opacity hover:opacity-100 ${
|
||||
r.done ? "opacity-35" : "opacity-90"
|
||||
}`}
|
||||
style={{
|
||||
marginLeft: left,
|
||||
width,
|
||||
background: `color-mix(in srgb, ${game.hue} 14%, var(--color-surface))`,
|
||||
borderLeft: `3px dashed ${game.hue}`,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="sticky flex min-w-0 flex-wrap content-start items-center gap-x-2 overflow-hidden"
|
||||
style={{
|
||||
left: PIN,
|
||||
maxWidth: nameFits(left, left + width),
|
||||
height: NAME_LINE,
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate">{r.title}</span>
|
||||
<span className="tnum shrink-0 whitespace-nowrap text-[0.6875rem] text-muted">
|
||||
{r.detail}
|
||||
</span>
|
||||
{r.label && (
|
||||
<span className="tnum shrink-0 whitespace-nowrap text-[0.6875rem] text-muted">
|
||||
{r.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const months = monthBoundaries(min, max);
|
||||
const weeks = weekBoundaries(min, max);
|
||||
// Every Monday is right at the default scale and illegible at the widest zoom
|
||||
@@ -295,38 +383,18 @@ export function Timeline({
|
||||
{/* The board's own header. The jump control lives out here rather than
|
||||
floating over the chart: pinned inside, it would sit on top of the
|
||||
calendar and cover the very dates it sends you back to. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-hairline px-4 py-2.5">
|
||||
<StackControl value={group} onChange={onGroup} />
|
||||
<BoardHeader
|
||||
group={group}
|
||||
onGroup={onGroup}
|
||||
controls={controls}
|
||||
canOut={canStep(dayWidth, -1)}
|
||||
canIn={canStep(dayWidth, 1)}
|
||||
onZoom={zoom}
|
||||
onToday={() => jumpToNow("smooth")}
|
||||
/>
|
||||
{afterHeader}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div role="group" aria-label="Scale" className="flex items-center gap-1">
|
||||
<ScaleButton
|
||||
label="Show a longer stretch of time"
|
||||
disabled={!canStep(dayWidth, -1)}
|
||||
onClick={() => zoom(-1)}
|
||||
>
|
||||
−
|
||||
</ScaleButton>
|
||||
<ScaleButton
|
||||
label="Show a shorter stretch of time in more detail"
|
||||
disabled={!canStep(dayWidth, 1)}
|
||||
onClick={() => zoom(1)}
|
||||
>
|
||||
+
|
||||
</ScaleButton>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => jumpToNow("smooth")}
|
||||
className="text-[0.6875rem] font-medium text-faint transition-colors duration-150 hover:text-ink"
|
||||
>
|
||||
Jump to today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plotted.length === 0 ? (
|
||||
{plotted.length === 0 && routines.length === 0 ? (
|
||||
/* Not the same emptiness as no rows at all: everything the reader can
|
||||
see is still ahead of them, and the board is hiding it on purpose.
|
||||
Say which, or the control above reads as broken. */
|
||||
@@ -344,9 +412,11 @@ export function Timeline({
|
||||
/*
|
||||
* The pane scrolls, not the page — which is what lets the axis stay
|
||||
* put. Capped rather than fixed: three lanes take the height they need
|
||||
* and nothing scrolls vertically at all.
|
||||
* and nothing scrolls vertically at all. Capped at the window's height,
|
||||
* so a full board fills the screen and the page footer stays below it
|
||||
* until the reader scrolls past the board's end.
|
||||
*/
|
||||
className="scroll-pane relative max-h-[72vh] overflow-auto overscroll-x-contain"
|
||||
className="scroll-pane relative max-h-[100dvh] overflow-auto overscroll-x-contain"
|
||||
onScroll={trackScroll}
|
||||
>
|
||||
<div className="relative" style={{ width: chartWidth, minWidth: "100%" }}>
|
||||
@@ -374,7 +444,18 @@ export function Timeline({
|
||||
className="pointer-events-none absolute bottom-0 top-10 z-10 w-px bg-critical/80"
|
||||
style={{ left: x(now) }}
|
||||
>
|
||||
<span className="eyebrow absolute top-1 -translate-x-1/2 rounded-[3px] bg-critical px-1 py-px text-[0.5625rem] leading-none text-ground">
|
||||
{/* Not `.eyebrow`: its faint ink left the word unreadable on
|
||||
the chip. */}
|
||||
<span
|
||||
className="absolute top-0.5 -translate-x-1/2 rounded px-2 py-[3px] text-[0.75rem] font-bold uppercase leading-none"
|
||||
style={{
|
||||
fontFamily: "var(--font-display)",
|
||||
background: NOW_RED,
|
||||
color: "#fff",
|
||||
letterSpacing: "0.1em",
|
||||
boxShadow: "0 0 0 2px var(--color-ground)",
|
||||
}}
|
||||
>
|
||||
now
|
||||
</span>
|
||||
</div>
|
||||
@@ -453,7 +534,8 @@ export function Timeline({
|
||||
)}
|
||||
|
||||
<div className="relative space-y-2">
|
||||
{lane.rows.map(({ event, clock }, i) => {
|
||||
{lane.rows.map((row, i) => {
|
||||
const { event, clock } = row;
|
||||
const game = gameMeta(event.game);
|
||||
const unknownEnd = clock.endsMs === null;
|
||||
const notStarted = clock.upcoming;
|
||||
@@ -477,13 +559,13 @@ export function Timeline({
|
||||
chartWidth,
|
||||
);
|
||||
const width = Math.max(right - left, MIN_BAR);
|
||||
const done = isDone(event.id);
|
||||
const done = isDoneRow ? isDoneRow(row) : isDone(event.id);
|
||||
return (
|
||||
<Fragment key={event.id}>
|
||||
<Fragment key={JSON.stringify([row.accountId ?? "", event.id])}>
|
||||
{i === breakAt && <NotStarted />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(event.id)}
|
||||
onClick={() => onOpenRow ? onOpenRow(row) : onOpen(event.id)}
|
||||
// The game is in the tooltip on the merged board
|
||||
// because the bar can be too narrow to show the tag
|
||||
// it carries, and colour alone is not an answer once
|
||||
@@ -491,7 +573,7 @@ export function Timeline({
|
||||
title={
|
||||
(heading === null
|
||||
? `${game.name} — ${event.title}`
|
||||
: event.title) +
|
||||
: event.title) + (row.region && servers(event.game) ? ` · ${row.region} server` : "") +
|
||||
(notStarted
|
||||
? ` — not started yet, begins ${dayLabel(clock.startsMs)}`
|
||||
: "")
|
||||
@@ -526,8 +608,8 @@ export function Timeline({
|
||||
{/* Sticky clamps to the bar's own box, so a name can
|
||||
never wander outside the event it belongs to. */}
|
||||
<span
|
||||
className="sticky flex min-w-0 items-center gap-1.5 truncate"
|
||||
style={{ left: PIN, maxWidth: nameFits(left, left + width) }}
|
||||
className="sticky flex min-w-0 flex-wrap content-start items-center gap-x-1.5 overflow-hidden"
|
||||
style={{ left: PIN, maxWidth: nameFits(left, left + width), height: NAME_LINE }}
|
||||
>
|
||||
{heading === null && width >= TAG_FROM && (
|
||||
<span
|
||||
@@ -537,7 +619,16 @@ export function Timeline({
|
||||
{game.short}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{event.title}</span>
|
||||
<span className="min-w-0 truncate">{event.title}{heading === null && row.region && servers(event.game) ? ` · ${row.region}` : ""}</span>
|
||||
{/* Inside the pinned name, after the title. It never
|
||||
shrinks: when it does not fit whole beside the
|
||||
title it wraps to a second line, which the
|
||||
one-line name hides, so the title always wins. */}
|
||||
{label?.(row) && (
|
||||
<span className="tnum shrink-0 whitespace-nowrap text-[0.6875rem] font-normal text-muted">
|
||||
{label(row)}
|
||||
</span>
|
||||
)}
|
||||
{notStarted && (
|
||||
/* The dashed edge and the labelled rule above it
|
||||
say this to a reader looking at the board; a
|
||||
@@ -559,10 +650,40 @@ export function Timeline({
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{routinesInline &&
|
||||
routines
|
||||
.filter((r) => heading === null || r.game === lane.game)
|
||||
.map(routineBar)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!routinesInline && routines.length > 0 && (
|
||||
<section aria-label="Routines" className="space-y-7">
|
||||
<div className="routine-divider border-t border-hairline pt-3">
|
||||
<p
|
||||
className="eyebrow sticky left-0 w-fit bg-ground pr-2 text-[0.625rem] text-muted"
|
||||
style={{ paddingLeft: PIN }}
|
||||
>
|
||||
Routines
|
||||
</p>
|
||||
</div>
|
||||
{[...new Set(routines.map((r) => r.game))].map((g) => (
|
||||
<div key={g}>
|
||||
<p
|
||||
className="eyebrow sticky left-0 z-20 mb-2.5 w-fit bg-ground pr-2 text-[0.625rem]"
|
||||
style={{ color: gameMeta(g).hue, paddingLeft: PIN }}
|
||||
title={gameMeta(g).name}
|
||||
>
|
||||
{gameMeta(g).short}
|
||||
</p>
|
||||
<div className="relative space-y-2">
|
||||
{routines.filter((r) => r.game === g).map(routineBar)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +692,98 @@ export function Timeline({
|
||||
);
|
||||
}
|
||||
|
||||
/** One routine period for the classic board's own section. */
|
||||
export interface ClassicRoutine {
|
||||
key: string;
|
||||
game: string;
|
||||
title: string;
|
||||
/** Short progress, such as "1/3". */
|
||||
detail: string;
|
||||
start: number;
|
||||
end: number;
|
||||
done: boolean;
|
||||
/** Time to reset or the reset date, when the reader asked for bar labels. */
|
||||
label?: string;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The now chip's fill: one strong red in every theme, under white text (5:1),
|
||||
* because the themed critical tone is tuned as text and washes out as a fill.
|
||||
*/
|
||||
const NOW_RED = "#d42a47";
|
||||
|
||||
/** One line of a bar's name; anything that wraps past it is hidden. */
|
||||
const NAME_LINE = "1.125rem";
|
||||
|
||||
/** Where the board opens: back to `startX`, with now 25–50% across. */
|
||||
export function openingLeft(
|
||||
nowX: number,
|
||||
startX: number,
|
||||
viewport: number,
|
||||
): number {
|
||||
return Math.max(
|
||||
nowX - viewport * 0.5,
|
||||
Math.min(nowX - viewport * 0.25, startX - OPEN_INSET),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The board's header: stacking pills, any extra toggle pills, then the scale
|
||||
* and today controls. Shared by both Timeline looks so their options match.
|
||||
*/
|
||||
export function BoardHeader({
|
||||
group,
|
||||
onGroup,
|
||||
controls,
|
||||
canOut,
|
||||
canIn,
|
||||
onZoom,
|
||||
onToday,
|
||||
}: {
|
||||
group: TimelineGroup;
|
||||
onGroup: (group: TimelineGroup) => void;
|
||||
controls?: ReactNode;
|
||||
canOut: boolean;
|
||||
canIn: boolean;
|
||||
onZoom: (by: 1 | -1) => void;
|
||||
onToday: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="board-header flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-hairline px-4 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<StackControl value={group} onChange={onGroup} />
|
||||
{controls}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div role="group" aria-label="Scale" className="flex items-center gap-1">
|
||||
<ScaleButton
|
||||
label="Show a longer stretch of time"
|
||||
disabled={!canOut}
|
||||
onClick={() => onZoom(-1)}
|
||||
>
|
||||
−
|
||||
</ScaleButton>
|
||||
<ScaleButton
|
||||
label="Show a shorter stretch of time in more detail"
|
||||
disabled={!canIn}
|
||||
onClick={() => onZoom(1)}
|
||||
>
|
||||
+
|
||||
</ScaleButton>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToday}
|
||||
className="text-[0.6875rem] font-medium text-faint transition-colors duration-150 hover:text-ink"
|
||||
>
|
||||
Jump to today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The span of time the board draws.
|
||||
*
|
||||
@@ -613,23 +826,51 @@ function StackControl({
|
||||
onChange: (group: TimelineGroup) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label="Stack the timeline" className="flex gap-1">
|
||||
{TIMELINE_GROUPS.map((mode) => {
|
||||
const on = value === mode.id;
|
||||
<PillChoice
|
||||
label="Stack the timeline"
|
||||
value={value}
|
||||
options={TIMELINE_GROUPS}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An either/or choice drawn as two pills sharing one outline, the chosen side
|
||||
* filled. Every option in the board's header uses it, so each reads as one
|
||||
* question with two answers rather than a switch to be found and undone.
|
||||
*/
|
||||
export function PillChoice<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly { id: T; label: string; hint?: string }[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="inline-flex rounded-full border border-hairline p-0.5"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const on = value === option.id;
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(mode.id)}
|
||||
onClick={() => onChange(option.id)}
|
||||
aria-pressed={on}
|
||||
title={mode.hint}
|
||||
className={`rounded-full border px-2.5 py-1 text-[0.6875rem] font-medium transition-colors ${
|
||||
on
|
||||
? "border-ink/60 text-ink"
|
||||
: "border-transparent text-faint hover:text-muted"
|
||||
title={option.hint}
|
||||
className={`rounded-full px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors ${
|
||||
on ? "bg-raised text-ink" : "text-faint hover:text-muted"
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import type { SourceHealth } from "../../../shared/feed.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import {
|
||||
AUTHOR,
|
||||
BUG_URL,
|
||||
FEATURE_URL,
|
||||
IDEA_CREDITS,
|
||||
REPO_URL,
|
||||
RESET_CLOCK_CREDIT,
|
||||
siteFor,
|
||||
} from "../Colophon.tsx";
|
||||
|
||||
/**
|
||||
* The Round 6 B “About Event Clock” pane: credits and the disclaimer only.
|
||||
* Data freshness lives in Data & sources.
|
||||
*/
|
||||
export function AboutPage({
|
||||
sources,
|
||||
}: {
|
||||
/** Every source read, so every editor and game is credited, not just the reader's. */
|
||||
sources: SourceHealth[];
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const sites = [
|
||||
...new Map(
|
||||
sources.map((s) => siteFor(s.url)).map((s) => [s.url, s]),
|
||||
).values(),
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const games = [...new Set(sources.map((s) => s.game))];
|
||||
const studios = [
|
||||
...new Set(sources.map((s) => meta(s.game).studio).filter(Boolean)),
|
||||
];
|
||||
const link = (href: string, label: ReactNode) => (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
const dotted = (items: ReactNode[]) =>
|
||||
items.map((item, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && " · "}
|
||||
{item}
|
||||
</Fragment>
|
||||
));
|
||||
return (
|
||||
<section className="about-page">
|
||||
<h1>About Event Clock</h1>
|
||||
<div className="about-prose">
|
||||
<p>
|
||||
Live and upcoming gacha events, sorted by what expires next. An
|
||||
unofficial, fan-made calendar with progress saved in your browser.
|
||||
</p>
|
||||
<h3>Made by</h3>
|
||||
<p>
|
||||
{link(AUTHOR.site, AUTHOR.name)} ·{" "}
|
||||
{link(AUTHOR.github, "StereotypicalCat")}
|
||||
</p>
|
||||
<p className="about-links">
|
||||
{link(REPO_URL, "Source code")}
|
||||
{link(BUG_URL, "Report a problem")}
|
||||
{link(FEATURE_URL, "Request a feature")}
|
||||
{link(AUTHOR.kofi, "Support the project")}
|
||||
</p>
|
||||
<h3>With thanks to the source editors</h3>
|
||||
<p>
|
||||
The source communities compile and maintain the schedules; Event Clock
|
||||
rearranges their work. {dotted(sites.map((s) => link(s.url, s.name)))}
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
{link(RESET_CLOCK_CREDIT.url, RESET_CLOCK_CREDIT.name)} provides the
|
||||
reference for per-server reset clocks.
|
||||
</p>
|
||||
<h3>The studios and games</h3>
|
||||
<p>
|
||||
{studios.join(" · ")}, who make the games worth keeping track of:{" "}
|
||||
{dotted(
|
||||
games.map((g) => (
|
||||
<span className="game-name" style={{ color: meta(g).hue }}>
|
||||
{meta(g).name}
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
. All game names, event names and trademarks belong to their
|
||||
respective owners.
|
||||
</p>
|
||||
<h3>Ideas and acknowledgements</h3>
|
||||
<p>{dotted(IDEA_CREDITS.map((c) => link(c.url, c.handle)))}.</p>
|
||||
<h3>Unofficial and unaffiliated</h3>
|
||||
<p>
|
||||
Not endorsed by or connected to any game publisher or source named
|
||||
here. Dates can be wrong or go out of date: check the original source
|
||||
before the final hours.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Prefs } from "../../state/usePrefs.ts";
|
||||
export type AppPage = "soon" | "timeline" | "routines" | "archive" | "settings";
|
||||
export const APP_PAGES: readonly [AppPage, string][] = [
|
||||
["soon", "Checklist"],
|
||||
["timeline", "Timeline"],
|
||||
["routines", "Routines"],
|
||||
["archive", "Archive"],
|
||||
["settings", "Settings"],
|
||||
];
|
||||
export function mainViewPatch(page: AppPage): Partial<Prefs> {
|
||||
return page === "soon" || page === "timeline" ? { view: page } : {};
|
||||
}
|
||||
export function AppNavigation({
|
||||
page,
|
||||
onPage,
|
||||
counts = {},
|
||||
}: {
|
||||
counts?: Partial<Record<AppPage, number>>;
|
||||
page: AppPage;
|
||||
onPage: (page: AppPage) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="app-navigation" aria-label="Main pages">
|
||||
{APP_PAGES.map(([id, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={id === "settings" ? "gear" : undefined}
|
||||
aria-current={page === id ? "page" : undefined}
|
||||
onClick={() => onPage(id)}
|
||||
>
|
||||
{label}
|
||||
{counts[id] !== undefined && (
|
||||
<span className="n">
|
||||
{counts[id]}
|
||||
{id === "routines" ? " left" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { recordOf, actionMeta } from "../../state/reader/actions.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { ChecklistDisplay } from "./RoutinesPage.tsx";
|
||||
import { timelineLook } from "./TimelinePage.tsx";
|
||||
import { ViewCards } from "./ViewCards.tsx";
|
||||
import { focusBarShown } from "./FocusPicker.tsx";
|
||||
import { URGENCY_STEPS, urgencyChoice } from "../../state/appearance.ts";
|
||||
|
||||
/** The live legend's words for each step. */
|
||||
const URGENCY_LABEL = {
|
||||
calm: "A week or more",
|
||||
near: "Under a week",
|
||||
soon: "Under 3 days",
|
||||
critical: "Under a day",
|
||||
} as const;
|
||||
export function AppearancePage({
|
||||
state,
|
||||
onAction,
|
||||
onReplayTour,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
/** Starts the returning tour at its first step. */
|
||||
onReplayTour?: () => void;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const p = state.prefs,
|
||||
patch = (patch: Record<string, unknown>) =>
|
||||
void onAction({ type: "prefs", patch, ...actionMeta() });
|
||||
const hidden = Array.isArray(p.hiddenGames)
|
||||
? (p.hiddenGames as string[])
|
||||
: [];
|
||||
const saved = Array.isArray(p.gameOrder)
|
||||
? (p.gameOrder as string[])
|
||||
: undefined;
|
||||
const byName = (g: string) => meta(g).name;
|
||||
const accounts = Object.values(state.accounts).filter((a) => !a.retiredAt);
|
||||
// Only the games being tracked: not hidden, with events or a followed routine.
|
||||
const tracked = orderGames(
|
||||
[
|
||||
...new Set(
|
||||
accounts
|
||||
.filter(
|
||||
(a) =>
|
||||
!hidden.includes(a.game) &&
|
||||
(a.intent.events ||
|
||||
Object.values(a.routines).some((r) => r.followed)),
|
||||
)
|
||||
.map((a) => a.game),
|
||||
),
|
||||
],
|
||||
saved,
|
||||
byName,
|
||||
);
|
||||
const untracked = orderGames(
|
||||
[...new Set(accounts.map((a) => a.game))].filter(
|
||||
(g) => !tracked.includes(g),
|
||||
),
|
||||
saved,
|
||||
byName,
|
||||
);
|
||||
const move = (index: number, by: -1 | 1) => {
|
||||
const next = tracked.slice();
|
||||
const [g] = next.splice(index, 1);
|
||||
next.splice(index + by, 0, g!);
|
||||
patch({ gameOrder: [...next, ...untracked] });
|
||||
};
|
||||
return (
|
||||
<section>
|
||||
<h1>Appearance</h1>
|
||||
<div className="interface-choices">
|
||||
{(["structured", "relaxed"] as const).map((id) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={(p.interface ?? "structured") === id}
|
||||
key={id}
|
||||
onClick={() => patch({ interface: id })}
|
||||
>
|
||||
<span className={`interface-preview ${id}`} aria-hidden>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<strong>{id === "relaxed" ? "Relaxed" : "Structured"}</strong>
|
||||
<small>
|
||||
{id === "relaxed"
|
||||
? "Separated panels with more breathing room."
|
||||
: "A compact, ruled planner."}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<section className="settings-group">
|
||||
<h2>Theme</h2>
|
||||
<p className="help">
|
||||
Choose a light or dark workspace, or follow your device.
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{["dark", "light", "system"].map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
aria-pressed={(p.theme ?? "dark") === theme}
|
||||
onClick={() => patch({ theme })}
|
||||
>
|
||||
{theme[0]!.toUpperCase() + theme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Colour scheme</h2>
|
||||
<div className="scheme-choices">
|
||||
{["slate", "warm", "plum", "custom"].map((s) => (
|
||||
<button
|
||||
className={`scheme-option scheme-${s}`}
|
||||
key={s}
|
||||
aria-pressed={(p.scheme ?? "slate") === s}
|
||||
onClick={() => patch({ scheme: s })}
|
||||
>
|
||||
<span className="scheme-swatch" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
{s[0]!.toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{p.scheme === "custom" && (
|
||||
<div className="custom-palettes">
|
||||
{["dark", "light"].map((theme) => (
|
||||
<fieldset key={theme}>
|
||||
<legend>
|
||||
{theme === "dark" ? "Dark colours" : "Light colours"}
|
||||
</legend>
|
||||
{["ground", "surface", "text", "accent"].map((k) => (
|
||||
<label key={k}>
|
||||
{k}
|
||||
<input
|
||||
type="color"
|
||||
value={String(
|
||||
recordOf(recordOf(p.customPalette)[theme])[k] ??
|
||||
(theme === "dark"
|
||||
? k === "text"
|
||||
? "#eeeae3"
|
||||
: "#22252f"
|
||||
: k === "text"
|
||||
? "#28272d"
|
||||
: "#fffdf8"),
|
||||
)}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
customPalette: {
|
||||
...recordOf(p.customPalette),
|
||||
[theme]: {
|
||||
...recordOf(recordOf(p.customPalette)[theme]),
|
||||
[k]: e.target.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="help">
|
||||
Your chosen colours are saved. Displayed text and surfaces adjust to
|
||||
keep them readable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<section className="settings-group urgency-colours">
|
||||
<h2>Urgency colours</h2>
|
||||
<p className="help">
|
||||
Countdowns and time bars change colour as an event's end gets closer.
|
||||
Pick each step for dark and light. They work with any scheme, and are
|
||||
only adjusted if needed to stay readable.
|
||||
</p>
|
||||
<div className="custom-palettes">
|
||||
{(["dark", "light"] as const).map((theme) => {
|
||||
const chosen = urgencyChoice(p, theme),
|
||||
own = recordOf(recordOf(p.urgencyPalette)[theme]);
|
||||
return (
|
||||
<fieldset key={theme}>
|
||||
<legend>
|
||||
{theme === "dark" ? "Dark theme" : "Light theme"}
|
||||
</legend>
|
||||
{URGENCY_STEPS.map((k) => (
|
||||
<label key={k} style={{ "--u": chosen[k] } as CSSProperties}>
|
||||
<input
|
||||
type="color"
|
||||
value={chosen[k]}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
urgencyPalette: {
|
||||
...recordOf(p.urgencyPalette),
|
||||
[theme]: { ...own, [k]: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="urgency-sample" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
{URGENCY_LABEL[k]}
|
||||
</label>
|
||||
))}
|
||||
{Object.keys(own).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
onClick={() =>
|
||||
patch({
|
||||
urgencyPalette: {
|
||||
...recordOf(p.urgencyPalette),
|
||||
[theme]: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Use the default colours
|
||||
</button>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Default view</h2>
|
||||
<p className="help">
|
||||
What opens when you come back. What's new shows in both, so you won't
|
||||
miss it either way.
|
||||
</p>
|
||||
<ViewCards
|
||||
value={p.view === "timeline" ? "timeline" : "soon"}
|
||||
onChange={(view) => patch({ view })}
|
||||
/>
|
||||
<div className="acts">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
onReplayTour ? onReplayTour() : patch({ redesignTourSeen: null })
|
||||
}
|
||||
>
|
||||
Replay the introduction
|
||||
</button>
|
||||
<span className="help">
|
||||
Your games, events and progress stay in this browser.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
{tracked.length > 1 && (
|
||||
<section className="settings-group game-order">
|
||||
<h2>Game order</h2>
|
||||
<p className="help">
|
||||
The games you track, in the order focus, routines and the timeline
|
||||
list them. Move the ones you play most to the top.
|
||||
</p>
|
||||
<ol>
|
||||
{tracked.map((g, i) => (
|
||||
<li key={g} style={{ "--g": meta(g).hue } as CSSProperties}>
|
||||
<span className="settings-marker" aria-hidden="true" />
|
||||
<span className="name">{meta(g).name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="move"
|
||||
aria-label={`Move ${meta(g).name} up`}
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 15l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="move"
|
||||
aria-label={`Move ${meta(g).name} down`}
|
||||
disabled={i === tracked.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => patch({ gameOrder: null })}
|
||||
>
|
||||
Sort A–Z
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<section className="settings-group">
|
||||
<h2>Timeline design</h2>
|
||||
<p className="help">
|
||||
The new look puts a name column beside each bar. The classic look
|
||||
draws filled bars with the title inside, grouped under each game.
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{(["new", "classic"] as const).map((look) => (
|
||||
<button
|
||||
key={look}
|
||||
aria-pressed={timelineLook(p) === look}
|
||||
onClick={() => patch({ timelineLook: look })}
|
||||
>
|
||||
{look === "new" ? "New look" : "Classic look"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Focus bar</h2>
|
||||
<p className="help">
|
||||
The game pills that narrow a view to one game. A view without them
|
||||
always shows every game.
|
||||
</p>
|
||||
{(["checklist", "timeline"] as const).map((view) => (
|
||||
<label key={view}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={focusBarShown(p, view)}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
focusBar: {
|
||||
checklist: focusBarShown(p, "checklist"),
|
||||
timeline: focusBarShown(p, "timeline"),
|
||||
[view]: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
Show on the {view === "checklist" ? "Checklist" : "Timeline"}
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
<section className="settings-group checklist-settings">
|
||||
<h2>Checklist</h2>
|
||||
<div className="setting-fields">
|
||||
<label>
|
||||
Event rows
|
||||
<select
|
||||
value={String(p.density ?? "standard")}
|
||||
onChange={(e) => patch({ density: e.target.value })}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</label>
|
||||
<ChecklistDisplay state={state} onAction={onAction} plain />
|
||||
</div>
|
||||
<p className="help">
|
||||
Focusing a game opens its routines automatically. Pins on the Routines
|
||||
tab decide which routines appear on your Checklist.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import { actionMeta, recordOf } from "../../state/reader/actions.ts";
|
||||
import type { ArchiveRecord, ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
|
||||
/** An archive is a saved snapshot, including its clock and progress, not a live editor. */
|
||||
export function ArchiveDetail({
|
||||
record,
|
||||
state,
|
||||
onAction,
|
||||
onClose,
|
||||
}: {
|
||||
record: ArchiveRecord;
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const meta = useGameMeta()(record.event.game);
|
||||
const account = state.accounts[record.accountId];
|
||||
const clock = account?.clocks.find((c) => c.id === record.clockRevision);
|
||||
const progress = recordOf(record.progress);
|
||||
const zone =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const outcome = {
|
||||
done: "Completed",
|
||||
ignored: "Ignored",
|
||||
unfinished: "Ended unfinished",
|
||||
}[record.outcome];
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="archive-detail event-detail-sheet"
|
||||
aria-label={record.event.title}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="drawer-head">
|
||||
<p className="detail-game" style={{ color: meta.hue }}>
|
||||
{meta.name}
|
||||
{serverSuffix(
|
||||
state,
|
||||
record.event.game,
|
||||
clock?.region ?? account?.region,
|
||||
true,
|
||||
)}
|
||||
</p>
|
||||
<button onClick={onClose} aria-label="Close archived details">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="detail-title">{record.event.title}</h2>
|
||||
{record.event.summary && <p className="help">{record.event.summary}</p>}
|
||||
<dl className="archive-facts">
|
||||
<div>
|
||||
<dt>Outcome</dt>
|
||||
<dd>
|
||||
{outcome} · {formatInZone(record.outcomeAt, true, zone)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>End recorded</dt>
|
||||
<dd>
|
||||
{record.resolvedEnd === null
|
||||
? "Unannounced"
|
||||
: formatInZone(
|
||||
new Date(record.resolvedEnd).toISOString(),
|
||||
record.event.endPrecision === "exact",
|
||||
zone,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Saved progress</dt>
|
||||
<dd>
|
||||
{record.days.length} check-ins
|
||||
{typeof progress.effort === "string" ? ` · ${progress.effort}` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{typeof progress.note === "string" && progress.note && (
|
||||
<section>
|
||||
<h3>Your note</h3>
|
||||
<p className="archive-note">{progress.note}</p>
|
||||
</section>
|
||||
)}
|
||||
{record.days.length > 0 && (
|
||||
<details>
|
||||
<summary>Recorded check-ins</summary>
|
||||
<p>{record.days.join(" · ")}</p>
|
||||
</details>
|
||||
)}
|
||||
{Object.keys(record.amounts).length > 0 && (
|
||||
<details>
|
||||
<summary>Recorded amounts</summary>
|
||||
<dl>
|
||||
{Object.entries(record.amounts).map(([period, raw]) => (
|
||||
<div key={period}>
|
||||
<dt>{period}</dt>
|
||||
<dd>{String(recordOf(raw).value ?? raw)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
{record.event.sourceUrl && (
|
||||
<p>
|
||||
<a
|
||||
className="source-link"
|
||||
href={record.event.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Original source ↗
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="help">
|
||||
Saved when this event entered your archive. Restoring keeps its
|
||||
progress; an event that has already ended remains in history.
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={async () => {
|
||||
const result = await onAction({
|
||||
type: "restore",
|
||||
accountId: record.accountId,
|
||||
eventId: record.event.id,
|
||||
...actionMeta(),
|
||||
});
|
||||
if (result.ok) onClose();
|
||||
}}
|
||||
>
|
||||
Restore event
|
||||
</button>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ReaderState, ArchiveRecord } from "../../state/reader/schema.ts";
|
||||
import { pairKey } from "../../state/reader/schema.ts";
|
||||
import { ArchiveStory } from "./ArchiveStory.tsx";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { DAY } from "../../../shared/time.ts";
|
||||
|
||||
export function ArchivePage({
|
||||
state,
|
||||
onAction,
|
||||
now,
|
||||
onOpen,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
onOpen: (record: ArchiveRecord) => void;
|
||||
}) {
|
||||
const [game, setGame] = useState<string | null>(null),
|
||||
[outcome, setOutcome] = useState("all"),
|
||||
[history, setHistory] = useState(false),
|
||||
meta = useGameMeta();
|
||||
const records = Object.values(state.archive).sort((a, b) =>
|
||||
b.outcomeAt.localeCompare(a.outcomeAt),
|
||||
);
|
||||
const games = [...new Set(records.map((r) => r.event.game))],
|
||||
zone =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const shown = records.filter(
|
||||
(r) =>
|
||||
(!game || r.event.game === game) &&
|
||||
(outcome === "all" || outcome === r.outcome),
|
||||
);
|
||||
const start =
|
||||
Math.floor(
|
||||
Math.min(now, ...shown.map((r) => Date.parse(r.event.startsAt))) / DAY,
|
||||
) * DAY;
|
||||
const end = Math.max(
|
||||
start + DAY,
|
||||
now,
|
||||
...shown.map((r) => r.resolvedEnd ?? Date.parse(r.outcomeAt)),
|
||||
);
|
||||
const days = Math.ceil((end - start) / DAY),
|
||||
trackWidth = Math.max(800, days * 16);
|
||||
const summary = (items: ArchiveRecord[]) =>
|
||||
`${items.filter((r) => r.outcome === "done").length} of ${items.filter((r) => r.outcome !== "ignored").length} done · ${items.filter((r) => r.outcome === "unfinished").length} unfinished`;
|
||||
const outcomeBar = (items: ArchiveRecord[]) => (
|
||||
<span className="archive-outcome-bar" aria-hidden="true">
|
||||
{items.map((r) => (
|
||||
<i key={pairKey(r.accountId, r.event.id)} className={r.outcome} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
const restore = (r: ArchiveRecord) =>
|
||||
void onAction({
|
||||
type: "restore",
|
||||
accountId: r.accountId,
|
||||
eventId: r.event.id,
|
||||
...actionMeta(),
|
||||
});
|
||||
return (
|
||||
<section className="archive-page">
|
||||
<div className="archive-view">
|
||||
<div className="segmented">
|
||||
<button aria-pressed={!history} onClick={() => setHistory(false)}>
|
||||
By game
|
||||
</button>
|
||||
<button aria-pressed={history} onClick={() => setHistory(true)}>
|
||||
History timeline
|
||||
</button>
|
||||
</div>
|
||||
<p className="help">Recorded history · kept in this browser</p>
|
||||
</div>
|
||||
<ArchiveStory records={records} now={now} game={game} onGame={setGame} />
|
||||
<div className="rt-top">
|
||||
<h1>Archive</h1>
|
||||
<p className="sum">
|
||||
Finished, ignored or ended. Kept in your save and off the Checklist
|
||||
and Timeline. Open one to see it as it was.
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{["all", "done", "unfinished", "ignored"].map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
aria-pressed={o === outcome}
|
||||
onClick={() => setOutcome(o)}
|
||||
>
|
||||
{
|
||||
{
|
||||
all: "All",
|
||||
done: "Completed",
|
||||
unfinished: "Ended unfinished",
|
||||
ignored: "Ignored",
|
||||
}[o]
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="archive-body">
|
||||
<nav className="archive-sidebar" aria-label="Archive games">
|
||||
<button aria-pressed={game === null} onClick={() => setGame(null)}>
|
||||
All games<small>{records.length} events</small>
|
||||
</button>
|
||||
{games.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
aria-pressed={game === g}
|
||||
onClick={() => setGame(g)}
|
||||
style={{ "--g": meta(g).hue } as CSSProperties}
|
||||
>
|
||||
<strong>{meta(g).name}</strong>
|
||||
<small>
|
||||
{summary(records.filter((r) => r.event.game === g))}
|
||||
</small>
|
||||
{outcomeBar(records.filter((r) => r.event.game === g))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="archive-main">
|
||||
{!shown.length && (
|
||||
<p className="empty-state">
|
||||
{records.length
|
||||
? "No events match these filters."
|
||||
: "No archived events yet. Completed and ignored events will appear here."}
|
||||
</p>
|
||||
)}
|
||||
{history && shown.length > 0 ? (
|
||||
<div className="timeline-scroller archive-timeline">
|
||||
<div
|
||||
className="timeline-board"
|
||||
style={
|
||||
{
|
||||
"--track-width": `${trackWidth}px`,
|
||||
"--day-width": `${trackWidth / days}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="time-axis">
|
||||
<strong>Recorded events</strong>
|
||||
<div className="ticks">
|
||||
{Array.from({ length: Math.ceil(days / 7) }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{ left: ((i * 7) / days) * trackWidth }}
|
||||
>
|
||||
{formatInZone(start + i * 7 * DAY, false, zone).replace(
|
||||
/ \d{4}$/,
|
||||
"",
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{shown.map((r) => (
|
||||
<div
|
||||
className="timeline-entry"
|
||||
key={pairKey(r.accountId, r.event.id)}
|
||||
style={{ "--g": meta(r.event.game).hue } as CSSProperties}
|
||||
>
|
||||
<button className="timeline-name" onClick={() => onOpen(r)}>
|
||||
{r.event.title}
|
||||
<small>
|
||||
{meta(r.event.game).short} · {r.outcome}
|
||||
</small>
|
||||
</button>
|
||||
<div className="time-track">
|
||||
<button
|
||||
className={`period-block archive-period ${r.outcome}${r.resolvedEnd === null ? " unknown-end" : ""}`}
|
||||
onClick={() => onOpen(r)}
|
||||
style={{
|
||||
left:
|
||||
((Date.parse(r.event.startsAt) - start) /
|
||||
(end - start)) *
|
||||
trackWidth,
|
||||
width: Math.max(
|
||||
18,
|
||||
(((r.resolvedEnd ?? Date.parse(r.outcomeAt)) -
|
||||
Date.parse(r.event.startsAt)) /
|
||||
(end - start)) *
|
||||
trackWidth,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{r.resolvedEnd === null ? "End unknown" : r.outcome}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
games
|
||||
.filter((g) => !game || game === g)
|
||||
.map((g) => {
|
||||
const own = shown.filter((r) => r.event.game === g);
|
||||
return (
|
||||
own.length > 0 && (
|
||||
<section
|
||||
className="archive-game"
|
||||
key={g}
|
||||
style={{ "--g": meta(g).hue } as CSSProperties}
|
||||
>
|
||||
<h2>{meta(g).name}</h2>
|
||||
<div className="archive-saved">
|
||||
<div className="archive-saved-heading">
|
||||
<h3>Saved events</h3>
|
||||
<small>{summary(own)}</small>
|
||||
{outcomeBar(own)}
|
||||
</div>
|
||||
{own.map((r) => (
|
||||
<article
|
||||
className="archive-record"
|
||||
key={pairKey(r.accountId, r.event.id)}
|
||||
>
|
||||
<button
|
||||
className="archive-open"
|
||||
onClick={() => onOpen(r)}
|
||||
title={r.event.summary ?? undefined}
|
||||
>
|
||||
<span
|
||||
className={`outcome-symbol ${r.outcome}`}
|
||||
aria-label={r.outcome}
|
||||
>
|
||||
{r.outcome === "done"
|
||||
? "✓"
|
||||
: r.outcome === "ignored"
|
||||
? "−"
|
||||
: "!"}
|
||||
</span>
|
||||
<span className="event-link">
|
||||
{r.event.title}
|
||||
<small>
|
||||
{serverSuffix(
|
||||
state,
|
||||
r.event.game,
|
||||
state.accounts[r.accountId]?.region,
|
||||
true,
|
||||
).slice(3)}
|
||||
</small>
|
||||
</span>
|
||||
<span className="archive-when">
|
||||
{formatInZone(r.outcomeAt, false, zone)}
|
||||
</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
<button
|
||||
className="archive-restore"
|
||||
onClick={() => restore(r)}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
<p className="help archive-history-note">
|
||||
History from before this redesign may be incomplete. Unknown end
|
||||
dates stay unknown; earlier progress is preserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { archiveStats } from "../../state/reader/archiveStats.ts";
|
||||
import type { ArchiveRecord } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { CSSProperties } from "react";
|
||||
export function ArchiveStory({
|
||||
records,
|
||||
now,
|
||||
onGame,
|
||||
game,
|
||||
}: {
|
||||
records: ArchiveRecord[];
|
||||
now: number;
|
||||
onGame?: (game: string | null) => void;
|
||||
game?: string | null;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const s = archiveStats(records, now);
|
||||
return (
|
||||
<section className="archive-story">
|
||||
<h2>Your season, in a few numbers</h2>
|
||||
<p>
|
||||
Your recorded history ·{" "}
|
||||
{records.filter((r) => r.outcome === "done").length} completed,{" "}
|
||||
{records.filter((r) => r.outcome === "ignored").length} set aside. A
|
||||
record of your saved events.
|
||||
</p>
|
||||
<div className="story-stats">
|
||||
<div>
|
||||
<strong>
|
||||
{s.monthly.rate === null ? "—" : s.monthly.rate + "%"}
|
||||
</strong>
|
||||
<span>completed this month</span>
|
||||
<small>
|
||||
{s.overall.rate === null
|
||||
? "No eligible history yet"
|
||||
: s.overall.rate + "% overall"}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{s.currentRun}</strong>
|
||||
<span>completion run</span>
|
||||
<small>Best: {s.bestRun}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{s.checkIns}</strong>
|
||||
<span>event check-ins</span>
|
||||
<small>Summed per event and server</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{s.unfinished}</strong>
|
||||
<span>ended unfinished</span>
|
||||
<small>Ignored events excluded from completion rates</small>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(s.games).map(([id, g]) => {
|
||||
const total = g.done + g.ignored + g.unfinished;
|
||||
return (
|
||||
<button
|
||||
className="stats-game"
|
||||
key={id}
|
||||
style={{ "--g": meta(id).hue } as CSSProperties}
|
||||
aria-pressed={game === id}
|
||||
onClick={() => onGame?.(game === id ? null : id)}
|
||||
>
|
||||
<span className="name">{meta(id).short}</span>
|
||||
<span className="outcome-stack" aria-hidden="true">
|
||||
<i
|
||||
className="done"
|
||||
style={{ width: `${(g.done / total) * 100}%` }}
|
||||
/>
|
||||
<i
|
||||
className="unfinished"
|
||||
style={{ width: `${(g.unfinished / total) * 100}%` }}
|
||||
/>
|
||||
<i
|
||||
className="ignored"
|
||||
style={{ width: `${(g.ignored / total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="counts">
|
||||
{g.done} done · {g.unfinished} unfinished · {g.ignored} ignored
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<p className="help">
|
||||
Recorded archive history only. Ignored events are excluded from
|
||||
completion rates. Select a game to filter the records below.
|
||||
</p>
|
||||
{s.shortfalls.map((r) => (
|
||||
<p key={r.id}>
|
||||
{r.title}: {r.needed} check-ins short of your recorded goal.
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { withReaderLock } from "../../state/reader/lock.ts";
|
||||
import { mergeReaderData } from "../../state/reader/import.ts";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
backupTier,
|
||||
deleteManualBackup,
|
||||
MANUAL_BACKUP_LIMIT,
|
||||
readBackups,
|
||||
readManualBackups,
|
||||
saveManualBackup,
|
||||
type BackupSnapshot,
|
||||
} from "../../state/reader/backups.ts";
|
||||
import { prepareRestore, undoRestore } from "../../state/reader/restore.ts";
|
||||
import {
|
||||
buildReaderExport,
|
||||
parseReaderExport,
|
||||
} from "../../state/reader/export.ts";
|
||||
import { readLegacy } from "../../state/reader/store.ts";
|
||||
import { triggerDownload } from "../../state/export.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
export function BackupPage({ state }: { state: ReaderState }) {
|
||||
const [backups] = useState(() => readBackups(localStorage));
|
||||
const [manual, setManual] = useState(() => readManualBackups(localStorage));
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<BackupSnapshot | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const zone =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const restore = async (payload: unknown) => {
|
||||
const result = await withReaderLock(() =>
|
||||
prepareRestore(localStorage, payload, new Date().toISOString()),
|
||||
).catch(() => ({ ok: false as const, reason: "unavailable" }));
|
||||
if (result.ok) location.reload();
|
||||
else
|
||||
setMessage(
|
||||
`Restore could not be completed (${result.reason}). Your current data is still available; export a copy before trying again.`,
|
||||
);
|
||||
};
|
||||
const importProgress = async (file: File) => {
|
||||
try {
|
||||
const parsed = parseReaderExport(JSON.parse(await file.text()));
|
||||
if (!parsed) {
|
||||
setMessage("This is not a supported Event Clock export.");
|
||||
return;
|
||||
}
|
||||
const merged = mergeReaderData(state, parsed);
|
||||
setSelected({
|
||||
id: "progress-import",
|
||||
at: new Date().toISOString(),
|
||||
payload: buildReaderExport(
|
||||
merged,
|
||||
readLegacy(localStorage),
|
||||
new Date().toISOString(),
|
||||
),
|
||||
});
|
||||
setMessage(
|
||||
"Progress will be merged. Your appearance and other local preferences will be kept.",
|
||||
);
|
||||
} catch {
|
||||
setMessage("This file could not be read as an Event Clock export.");
|
||||
}
|
||||
};
|
||||
const preview = selected ? parseReaderExport(selected.payload) : null;
|
||||
const now = Date.now();
|
||||
const summary = (payload: Record<string, unknown>) => {
|
||||
const r = parseReaderExport(payload);
|
||||
if (!r) return "Unreadable backup";
|
||||
// The games that snapshot was tracking: an account exists for every game.
|
||||
const hidden = Array.isArray(r.prefs.hiddenGames)
|
||||
? (r.prefs.hiddenGames as unknown[])
|
||||
: [];
|
||||
const accounts = Object.values(r.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!hidden.includes(a.game) &&
|
||||
(a.intent.events || Object.values(a.routines).some((p) => p.followed)),
|
||||
);
|
||||
const games = new Set(accounts.map((a) => a.game)).size;
|
||||
const routines = accounts.reduce(
|
||||
(n, a) => n + Object.values(a.routines).filter((p) => p.followed).length,
|
||||
0,
|
||||
);
|
||||
const archived = Object.keys(r.archive).length;
|
||||
return `${games} game${games === 1 ? "" : "s"} · ${routines} routine${routines === 1 ? "" : "s"} · ${archived} archived event${archived === 1 ? "" : "s"}`;
|
||||
};
|
||||
const tiers = [
|
||||
{ id: "daily", title: "Daily", span: "Last 7 days" },
|
||||
{
|
||||
id: "weekly",
|
||||
title: "Weekly",
|
||||
span: "Older snapshots, folded into 4 weeks",
|
||||
},
|
||||
{
|
||||
id: "monthly",
|
||||
title: "Monthly",
|
||||
span: "Older snapshots, folded into 6 months",
|
||||
},
|
||||
] as const;
|
||||
const fileButton = (label: string, onFile: (file: File) => void) => (
|
||||
<label className="btn file-button">
|
||||
{label}
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) onFile(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const importAll = async (file: File) => {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(await file.text());
|
||||
const reader = parseReaderExport(payload);
|
||||
if (!reader) {
|
||||
setMessage("This file is not a supported Event Clock backup.");
|
||||
return;
|
||||
}
|
||||
if ((payload as { scope?: unknown }).scope === "progress") {
|
||||
await importProgress(file);
|
||||
return;
|
||||
}
|
||||
setSelected({
|
||||
id: "import",
|
||||
at: new Date().toISOString(),
|
||||
payload: payload as Record<string, unknown>,
|
||||
});
|
||||
} catch {
|
||||
setMessage("This file could not be read as JSON.");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="backup-page">
|
||||
<h1>Backup</h1>
|
||||
<p className="muted-copy">
|
||||
Automatic snapshots keep a recent history of your data in this browser.
|
||||
The latest snapshot in each period is kept as older days fold into
|
||||
weeks, then months.
|
||||
</p>
|
||||
<dl className="backup-policy">
|
||||
<div>
|
||||
<dt>7 days</dt>
|
||||
<dd>Daily snapshots</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>4 weeks</dt>
|
||||
<dd>Weekly snapshots</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>6 months</dt>
|
||||
<dd>Monthly snapshots</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="source-note">
|
||||
Backups are created while you use the app. Days you don't open it have
|
||||
no new snapshot. These copies live in this browser, and clearing its
|
||||
storage removes them; export a file to keep a copy elsewhere.
|
||||
</p>
|
||||
<div className="backup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
onClick={() => {
|
||||
// Its own list: automatic history keeps one snapshot a day, and a
|
||||
// copy made on purpose must not replace it or be replaced by it.
|
||||
const result = saveManualBackup(
|
||||
localStorage,
|
||||
state,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
setMessage(
|
||||
result.ok
|
||||
? "Backup saved to Your backups."
|
||||
: result.reason === "full"
|
||||
? "Your backups are full. Delete one to make room; this keeps space free for your progress."
|
||||
: `Backup could not be saved (${result.reason}). Export a copy instead.`,
|
||||
);
|
||||
setManual(readManualBackups(localStorage));
|
||||
}}
|
||||
>
|
||||
Back up now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
buildReaderExport(
|
||||
state,
|
||||
readLegacy(localStorage),
|
||||
new Date().toISOString(),
|
||||
),
|
||||
`event-clock-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Export all data
|
||||
</button>
|
||||
{fileButton("Import all data", (file) => void importAll(file))}
|
||||
</div>
|
||||
<p className="backup-secondary">
|
||||
<span>Progress only, keeping this browser's settings:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
{
|
||||
...buildReaderExport(
|
||||
{ ...state, prefs: {} },
|
||||
readLegacy(localStorage),
|
||||
new Date().toISOString(),
|
||||
),
|
||||
scope: "progress",
|
||||
},
|
||||
"event-clock-progress.json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Export progress
|
||||
</button>
|
||||
<label className="btn link file-button">
|
||||
Import progress
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void importProgress(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
onClick={async () => {
|
||||
const result = await withReaderLock(() =>
|
||||
undoRestore(localStorage),
|
||||
).catch(() => ({ ok: false }));
|
||||
if (result.ok) location.reload();
|
||||
else setMessage("There is no recovery copy that can be restored.");
|
||||
}}
|
||||
>
|
||||
Undo last restore
|
||||
</button>
|
||||
</p>
|
||||
<p role="status" className="backup-message">
|
||||
{message}
|
||||
</p>
|
||||
{selected && preview && (
|
||||
<section className="backup-preview">
|
||||
<h2>Backup from {formatInZone(selected.at, true, zone)}</h2>
|
||||
<p>{summary(selected.payload)}</p>
|
||||
<p className="help">
|
||||
This replaces your current data. A recovery copy is saved first.
|
||||
</p>
|
||||
<div className="backup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
onClick={() => restore(selected.payload)}
|
||||
>
|
||||
Restore this backup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="backup-tier">
|
||||
<div className="backup-tier-h">
|
||||
<h3>Your backups</h3>
|
||||
<span>
|
||||
{manual.length} of {MANUAL_BACKUP_LIMIT} · Kept until you delete
|
||||
them
|
||||
</span>
|
||||
</div>
|
||||
{manual.length === 0 ? (
|
||||
<p className="help">
|
||||
Back up now saves a copy here. Automatic history never replaces it.
|
||||
</p>
|
||||
) : (
|
||||
manual.map((b) => (
|
||||
<div className="backup-row" key={b.id}>
|
||||
<div>
|
||||
<time dateTime={b.at}>{formatInZone(b.at, true, zone)}</time>
|
||||
<small>{summary(b.payload)}</small>
|
||||
</div>
|
||||
{confirming === b.id ? (
|
||||
<div className="backup-confirm">
|
||||
<span>Delete this backup?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm danger"
|
||||
onClick={() => {
|
||||
const result = deleteManualBackup(localStorage, b.id);
|
||||
setConfirming(null);
|
||||
if (selected?.id === b.id) setSelected(null);
|
||||
setManual(readManualBackups(localStorage));
|
||||
setMessage(
|
||||
result.ok
|
||||
? "Backup deleted."
|
||||
: `The backup could not be deleted (${result.reason}).`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => setConfirming(null)}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="backup-confirm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => setSelected(b)}
|
||||
>
|
||||
View backup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
aria-label={`Delete backup from ${formatInZone(b.at, true, zone)}`}
|
||||
onClick={() => setConfirming(b.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
<h2 className="backup-history-h">Automatic history</h2>
|
||||
{backups.length === 0 ? (
|
||||
<p className="help">
|
||||
No saved history yet. A backup is created when you use the app.
|
||||
</p>
|
||||
) : (
|
||||
tiers.map((tier) => {
|
||||
const rows = backups.filter((b) => backupTier(b.at, now) === tier.id);
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<section className="backup-tier" key={tier.id}>
|
||||
<div className="backup-tier-h">
|
||||
<h3>{tier.title}</h3>
|
||||
<span>
|
||||
{rows.length} snapshot{rows.length === 1 ? "" : "s"} ·{" "}
|
||||
{tier.span}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((b) => (
|
||||
<div className="backup-row" key={b.id}>
|
||||
<div>
|
||||
<time dateTime={b.at}>
|
||||
{formatInZone(b.at, true, zone)}
|
||||
</time>
|
||||
<small>{summary(b.payload)}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => setSelected(b)}
|
||||
>
|
||||
View backup
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
commitUrl,
|
||||
compareUrl,
|
||||
KIND_LABEL,
|
||||
specUrl,
|
||||
type Entry,
|
||||
type Note,
|
||||
} from "../../../shared/changelog.ts";
|
||||
import { asOfLabel } from "../../../shared/routines.ts";
|
||||
import { REPO_URL } from "../Colophon.tsx";
|
||||
|
||||
const KIND_ORDER: readonly Entry["kind"][] = ["feature", "games", "fix"];
|
||||
|
||||
/**
|
||||
* The Round 6 B changelog page (`.changelog-page .rel`): a dated, typed entry
|
||||
* per release note, its summary, and the commits it covers as a list, with the
|
||||
* commit range and design notes beneath. Loose commits follow by month.
|
||||
*/
|
||||
export function ChangelogPage({
|
||||
notes,
|
||||
entries,
|
||||
}: {
|
||||
notes: readonly Note[];
|
||||
entries: readonly Entry[];
|
||||
}) {
|
||||
const loose = entries.filter((e) => e.note === undefined);
|
||||
const months = [...new Set(loose.map((e) => e.date.slice(0, 7)))];
|
||||
return (
|
||||
<section className="changelog-page">
|
||||
<h1>Changelog</h1>
|
||||
<p className="muted-copy">
|
||||
Changes and release notes for Event Clock, summarised from the project's
|
||||
history and design notes. Each links to the commits it covers.
|
||||
</p>
|
||||
{notes.map((note) => {
|
||||
const covered = entries.filter((e) => e.note === note.id);
|
||||
const kinds = KIND_ORDER.filter((k) =>
|
||||
covered.some((e) => e.kind === k),
|
||||
);
|
||||
return (
|
||||
<article className="rel" key={note.id}>
|
||||
<div className="d">
|
||||
{asOfLabel(note.date)}
|
||||
{kinds.map((k) => ` · ${KIND_LABEL[k]}`).join("")}
|
||||
</div>
|
||||
<h3>{note.title}</h3>
|
||||
{note.summary.map((paragraph, i) => (
|
||||
<p key={i}>{paragraph}</p>
|
||||
))}
|
||||
{covered.length > 0 && (
|
||||
<ul>
|
||||
{covered.map((entry) => (
|
||||
<EntryItem key={entry.id} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="rel-links">
|
||||
<a
|
||||
href={compareUrl(REPO_URL, note.from, note.to)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Commits {note.from}…{note.to}
|
||||
</a>
|
||||
{(note.specs ?? []).map((path) => (
|
||||
<a
|
||||
key={path}
|
||||
href={specUrl(REPO_URL, path)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Spec: {path.split("/").at(-1)}
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{months.length > 0 && (
|
||||
<section className="rel other-changes">
|
||||
<h3>Other changes</h3>
|
||||
{months.map((month) => (
|
||||
<div key={month}>
|
||||
<div className="d">
|
||||
{asOfLabel(`${month}-01`).replace(/^1 /, "")}
|
||||
</div>
|
||||
<ul>
|
||||
{loose
|
||||
.filter((e) => e.date.startsWith(month))
|
||||
.map((entry) => (
|
||||
<EntryItem key={entry.id} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryItem({ entry }: { entry: Entry }) {
|
||||
return (
|
||||
<li>
|
||||
<span className="t">{entry.title}</span>
|
||||
<span className="meta">
|
||||
<span className={`kind ${entry.kind}`}>{KIND_LABEL[entry.kind]}</span>
|
||||
<a
|
||||
href={commitUrl(REPO_URL, entry.ref)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="hash"
|
||||
>
|
||||
{entry.ref}
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useContext, type CSSProperties } from "react";
|
||||
import type { ReaderEventRow } from "../../state/reader/eventRows.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import { Meter, URGENCY_COLOR } from "../Meter.tsx";
|
||||
import { STEPS } from "../Legend.tsx";
|
||||
import { isCustomEventId } from "../../../shared/custom.ts";
|
||||
|
||||
/** Compact's column headings: the Round 6 B ledger (`table.led`) as a grid over list rows. */
|
||||
/** The compact ledger's column labels; events not started yet count down to their start. */
|
||||
export function LedgerColumns({ upcoming = false }: { upcoming?: boolean }) {
|
||||
return (
|
||||
<div className="ledger-columns" aria-hidden="true">
|
||||
<span>Game</span>
|
||||
<span>Event</span>
|
||||
<span className="h-meter">{upcoming ? "Duration" : "Time left"}</span>
|
||||
<span>{upcoming ? "Starts in" : "Ends in"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Live's legend, said once under "Running now": what the bars and their
|
||||
* colours mean. Colours come from the same `URGENCY_COLOR`, so they follow
|
||||
* the reader's urgency colours.
|
||||
*/
|
||||
export function UrgencyLegend() {
|
||||
return (
|
||||
<div className="urgency-legend">
|
||||
<p>
|
||||
<span className="bars" aria-hidden="true">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<i key={i} data-on={i < 5 || undefined} />
|
||||
))}
|
||||
</span>
|
||||
bars = time left
|
||||
</p>
|
||||
<p>
|
||||
<span>colour = ends in</span>
|
||||
{STEPS.map((step) => (
|
||||
<span key={step.urgency} className="step">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
style={{ background: URGENCY_COLOR[step.urgency] }}
|
||||
/>
|
||||
{step.label}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function ChecklistEvent({
|
||||
row,
|
||||
now,
|
||||
doing,
|
||||
onOpen,
|
||||
server = false,
|
||||
compact = false,
|
||||
}: {
|
||||
row: ReaderEventRow;
|
||||
now: number;
|
||||
doing?: boolean;
|
||||
server?: boolean;
|
||||
compact?: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const meta = useGameMeta()(row.event.game),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
const { event, clock } = row;
|
||||
const remaining = clock.upcoming
|
||||
? `In ${formatRemaining(clock.startsMs - now)}`
|
||||
: clock.msRemaining === null
|
||||
? "Unknown"
|
||||
: formatRemaining(clock.msRemaining);
|
||||
const date = clock.upcoming ? clock.startsMs : clock.endsMs;
|
||||
const meter = (
|
||||
<Meter
|
||||
progress={clock.progress}
|
||||
urgency={clock.urgency}
|
||||
animate={false}
|
||||
label={
|
||||
clock.msRemaining === null ? "End date unknown" : `${remaining} left`
|
||||
}
|
||||
/>
|
||||
);
|
||||
const unknown = clock.msRemaining === null && !clock.upcoming;
|
||||
if (compact)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ledger-row"
|
||||
style={{ "--g": meta.hue } as CSSProperties}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="gcell">
|
||||
<span>
|
||||
{meta.short}
|
||||
{server && <small>{row.region}</small>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tcell">
|
||||
<span className="gsm">{meta.short}</span>
|
||||
{event.title}
|
||||
{doing && <span className="tag">Doing it</span>}
|
||||
{isCustomEventId(event.id) && <span className="tag own">Yours</span>}
|
||||
</span>
|
||||
<span className="mcell">{meter}</span>
|
||||
<span
|
||||
className="cd"
|
||||
style={{
|
||||
color: unknown
|
||||
? "var(--color-faint)"
|
||||
: URGENCY_COLOR[clock.urgency],
|
||||
}}
|
||||
>
|
||||
{unknown ? "End unknown" : remaining}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`event-entry${event.summary ? "" : " no-description"}`}
|
||||
style={{ "--g": meta.hue } as CSSProperties}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="event-copy">
|
||||
<span className="identity">
|
||||
{meta.name}
|
||||
{server && ` · ${row.region}`}
|
||||
{doing && " · Doing it"}
|
||||
{isCustomEventId(event.id) && " · Yours"}
|
||||
</span>
|
||||
<span className="event-title">{event.title}</span>
|
||||
{event.summary && (
|
||||
<span className="event-description">{event.summary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="deadline"
|
||||
style={{ color: URGENCY_COLOR[clock.urgency] }}
|
||||
>
|
||||
{remaining}
|
||||
<small>
|
||||
{date === null
|
||||
? "End not announced"
|
||||
: `${clock.upcoming ? "Starts" : "Ends"} ${formatInZone(new Date(date).toISOString(), false, zone).replace(/ \d{4}$/, "")}`}
|
||||
</small>
|
||||
{/* Every row shows its time left. The mockup hid it on rows without a
|
||||
description; the owner chose consistency over the tighter row. */}
|
||||
{meter}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 4 4 4-4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export function NextDeadline({
|
||||
row,
|
||||
then = [],
|
||||
servers = () => false,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ReaderEventRow | undefined;
|
||||
/** The next few after `row`, as in the Round 6 rail's `.then` list. */
|
||||
then?: readonly ReaderEventRow[];
|
||||
/** Whether a game is on several servers, so its server is named. */
|
||||
servers?: (game: string) => boolean;
|
||||
onOpen: (row: ReaderEventRow) => void;
|
||||
}) {
|
||||
const meta = useGameMeta(),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
if (!row)
|
||||
return (
|
||||
<section className="head">
|
||||
<p className="legend">Next to expire</p>
|
||||
<h2>All caught up</h2>
|
||||
<p className="help">No outstanding deadlines in this view.</p>
|
||||
</section>
|
||||
);
|
||||
const { event, clock } = row;
|
||||
return (
|
||||
<section className="head">
|
||||
<p className="legend">Next to expire</p>
|
||||
<button className="headline-event" onClick={() => onOpen(row)}>
|
||||
<h2>{event.title}</h2>
|
||||
<span className="game" style={{ color: meta(event.game).hue }}>
|
||||
{meta(event.game).name}
|
||||
{servers(event.game) && ` · ${row.region}`}
|
||||
</span>
|
||||
</button>
|
||||
<div className="big" style={{ color: URGENCY_COLOR[clock.urgency] }}>
|
||||
{clock.msRemaining === null
|
||||
? "Unknown"
|
||||
: formatRemaining(clock.msRemaining)}
|
||||
{clock.msRemaining !== null && <small>left</small>}
|
||||
</div>
|
||||
<p className="exact">
|
||||
{clock.endsMs === null
|
||||
? "End not announced"
|
||||
: `Ends ${formatInZone(new Date(clock.endsMs).toISOString(), true, zone)}`}
|
||||
</p>
|
||||
<Meter
|
||||
progress={clock.progress}
|
||||
urgency={clock.urgency}
|
||||
animate={false}
|
||||
label="Time remaining in this event"
|
||||
/>
|
||||
{then.length > 0 && (
|
||||
<ul className="then" aria-label="Then">
|
||||
{then.map((r) => (
|
||||
<li
|
||||
key={`${r.accountId} ${r.event.id}`}
|
||||
style={{ "--g": meta(r.event.game).hue } as CSSProperties}
|
||||
>
|
||||
<button type="button" onClick={() => onOpen(r)}>
|
||||
<span className="dot" aria-hidden="true" />
|
||||
<span className="t">{r.event.title}</span>
|
||||
<span
|
||||
className="r"
|
||||
style={{
|
||||
color:
|
||||
r.clock.msRemaining === null
|
||||
? "var(--color-faint)"
|
||||
: URGENCY_COLOR[r.clock.urgency],
|
||||
}}
|
||||
>
|
||||
{r.clock.msRemaining === null
|
||||
? "no end"
|
||||
: formatRemaining(r.clock.msRemaining)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { freshness } from "../../../shared/feed.ts";
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { newCodes } from "../../state/reader/codes.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
export function CodesPage({
|
||||
game,
|
||||
feed,
|
||||
state,
|
||||
onAction,
|
||||
}: {
|
||||
game: string;
|
||||
feed: CodesFeed | null;
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) => a.game === game && !a.retiredAt,
|
||||
);
|
||||
const codes = feed?.codes.filter((c) => c.game === game) ?? [];
|
||||
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
|
||||
useEffect(() => {
|
||||
if (unseen.length)
|
||||
void onAction({
|
||||
type: "seen",
|
||||
kind: "codeSeen",
|
||||
id: game,
|
||||
ids: unseen.map((c) => c.id),
|
||||
...actionMeta(),
|
||||
});
|
||||
}, [game, feed, onAction]);
|
||||
return (
|
||||
<section className="codes-page">
|
||||
<h2>Redeem codes</h2>
|
||||
<p className="help">
|
||||
New-code notices are separate from redemption. Mark codes redeemed for
|
||||
each account you play.
|
||||
</p>
|
||||
<p role="status">{message}</p>
|
||||
{feed &&
|
||||
freshness(
|
||||
feed.sources.filter((s) => s.game === game),
|
||||
Date.now(),
|
||||
).stale.length > 0 && (
|
||||
<p className="help">
|
||||
Code data may be out of date. Check the linked source before relying
|
||||
on a code's availability.
|
||||
</p>
|
||||
)}
|
||||
{!feed ? (
|
||||
<p>The codes feed is unavailable. Event tracking is still available.</p>
|
||||
) : !codes.length ? (
|
||||
<p>No published codes are available for this game.</p>
|
||||
) : (
|
||||
codes.map((c) => (
|
||||
<article className="code-row" key={c.id}>
|
||||
<div>
|
||||
<code>{c.code}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(c.code);
|
||||
setMessage(`Copied ${c.code}.`);
|
||||
} catch {
|
||||
setMessage(
|
||||
"Could not copy automatically. Select the code to copy it.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{c.reward && <p>{c.reward}</p>}
|
||||
<small>
|
||||
{c.expiresAt
|
||||
? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}`
|
||||
: c.expiresOn
|
||||
? `Expires on ${c.expiresOn} (time not stated)`
|
||||
: "Expiry not announced"}{" "}
|
||||
·{" "}
|
||||
<a href={c.sourceUrl} target="_blank" rel="noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
{accounts.map((a) => (
|
||||
<label key={a.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "redeem",
|
||||
accountId: a.id,
|
||||
codeId: c.id,
|
||||
redeemed: e.target.checked,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
Redeemed · {a.region}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { readerDayKey } from "../../../shared/readerClock.ts";
|
||||
import { formatRemaining, DAY, resetShiftMs } from "../../../shared/time.ts";
|
||||
import { streakOf } from "../../../shared/daily.ts";
|
||||
import { goalCount } from "../../../shared/goal.ts";
|
||||
import { chipLabel } from "../Dailies.tsx";
|
||||
import { Fireworks } from "../Fireworks.tsx";
|
||||
import { useSectionCelebration } from "./useSectionCelebration.ts";
|
||||
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
export function RoutineControl({
|
||||
row,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const today = readerDayKey(now, row.clock);
|
||||
const [amount, setAmount] = useState(String(row.status.count));
|
||||
const editing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!editing.current) setAmount(String(row.status.count));
|
||||
}, [row.status.count, row.status.periodStart]);
|
||||
return (
|
||||
<div className="routine-control">
|
||||
<div>
|
||||
<strong>{row.title}</strong>
|
||||
<small>
|
||||
{row.cadence === "day" ? "Today" : `This ${row.cadence}`} ·{" "}
|
||||
{row.status.count.toLocaleString()} /{" "}
|
||||
{row.status.required.toLocaleString()}
|
||||
</small>
|
||||
</div>
|
||||
{row.status.measure === "amount" ? (
|
||||
<label className="numeric-routine">
|
||||
<span className="sr-only">{row.title} progress</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
max="1000000000"
|
||||
value={amount}
|
||||
onFocus={() => {
|
||||
editing.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
editing.current = false;
|
||||
if (amount === "") setAmount(String(row.status.count));
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value);
|
||||
const value = e.target.valueAsNumber;
|
||||
if (Number.isInteger(value) && value >= 0)
|
||||
void onAction({
|
||||
type: "amount",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
period: row.status.periodStart,
|
||||
value,
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="daily-tick"
|
||||
aria-label={`${row.title}, ${row.days.includes(today) ? "done" : "not done"} today`}
|
||||
aria-pressed={row.days.includes(today)}
|
||||
onClick={() => toggleToday(row, today, onAction)}
|
||||
>
|
||||
<svg aria-hidden viewBox="0 0 16 16">
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
{row.days.includes(today) && (
|
||||
<path
|
||||
d="m4 8 3 3 5-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/** Today's tick for a daily row: on, or off again. */
|
||||
function toggleToday(row: RoutineRow, today: string, onAction: ActionHandler) {
|
||||
void onAction({
|
||||
type: "day",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
day: today,
|
||||
checked: !row.days.includes(today),
|
||||
...actionMeta(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A check-in goal: "5 days a week" and the like, as on Genshin's "To Temper
|
||||
* Thyself and Journey Far". A plain daily is a one-day event-scoped status.
|
||||
*/
|
||||
function hasGoal(row: RoutineRow): boolean {
|
||||
return (
|
||||
row.status.scope !== "day" &&
|
||||
!(row.status.scope === "event" && row.status.required === 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Done for now: ticked today, or its goal already met for the week (or the
|
||||
* event), as the live strip counts it. Nothing is ticked on a met goal's behalf.
|
||||
*/
|
||||
function doneToday(row: RoutineRow, today: string): boolean {
|
||||
if (row.status.measure === "amount") return row.status.met;
|
||||
return row.days.includes(today) || (hasGoal(row) && row.status.met);
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's dailies in the Checklist rail. The whole row is the tick target, as
|
||||
* the live strip's pill is. A tick washes the row in the game's colour, pops
|
||||
* the box and draws the check. The live strip's fireworks go off when the last
|
||||
* one is ticked, under the same rule as live (see `Dailies.tsx`): only a tick
|
||||
* made just now that finishes the list counts, not a list that got shorter.
|
||||
*/
|
||||
export function DailyPanel({
|
||||
rows,
|
||||
state,
|
||||
onAction,
|
||||
now,
|
||||
onManage,
|
||||
}: {
|
||||
onManage?: () => void;
|
||||
rows: RoutineRow[];
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const total = rows.length;
|
||||
const done = rows.filter((r) =>
|
||||
doneToday(r, readerDayKey(now, r.clock)),
|
||||
).length;
|
||||
const allDone = total > 0 && done === total;
|
||||
const burst = useSectionCelebration(total, done);
|
||||
// Rows ticked by a click in this panel; only those animate, so a reload
|
||||
// with everything already done doesn't replay every pop.
|
||||
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<section
|
||||
className="daily-panel"
|
||||
data-all-done={allDone || undefined}
|
||||
data-burst={burst > 0 || undefined}
|
||||
>
|
||||
{burst > 0 && <Fireworks key={burst} grand />}
|
||||
<div className="daily-heading">
|
||||
<h2>Today's dailies</h2>
|
||||
{onManage && <button onClick={onManage}>Edit pins</button>}
|
||||
</div>
|
||||
<div className="daily-progress">
|
||||
<span className="daily-pips" aria-hidden="true">
|
||||
{rows.map((row) => (
|
||||
<i
|
||||
key={row.accountId + row.id}
|
||||
data-on={
|
||||
doneToday(row, readerDayKey(now, row.clock)) || undefined
|
||||
}
|
||||
style={
|
||||
{
|
||||
"--g": meta(state.accounts[row.accountId]!.game).hue,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="daily-count" aria-live="polite">
|
||||
{allDone ? "All done for today" : `${done} of ${total} done`}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((row) => {
|
||||
const account = state.accounts[row.accountId]!;
|
||||
const s = resetShiftMs(account.region, account.game, row.clock),
|
||||
next = Math.floor((now + s) / DAY) * DAY + DAY - s;
|
||||
const today = readerDayKey(now, row.clock);
|
||||
const key = row.accountId + row.id;
|
||||
const ticked = row.days.includes(today);
|
||||
const isDone = doneToday(row, today);
|
||||
// Met this week without today's tick: done, drawn with a dotted check.
|
||||
const met = isDone && !ticked;
|
||||
const goal = hasGoal(row);
|
||||
const streak = streakOf(row.days, today);
|
||||
const label = (
|
||||
<span className="daily-text">
|
||||
<strong>
|
||||
{meta(account.game).short}
|
||||
{serverSuffix(state, account.game, account.region)}
|
||||
</strong>
|
||||
<small>
|
||||
{row.title} · reset {formatRemaining(next - now)}
|
||||
</small>
|
||||
</span>
|
||||
);
|
||||
const style = { "--g": meta(account.game).hue } as CSSProperties;
|
||||
if (row.status.measure === "amount")
|
||||
return (
|
||||
<div
|
||||
className="daily-row"
|
||||
key={key}
|
||||
style={style}
|
||||
data-done={isDone || undefined}
|
||||
>
|
||||
{label}
|
||||
<RoutineControl row={row} onAction={onAction} now={now} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="daily-row daily-hit"
|
||||
key={key}
|
||||
style={style}
|
||||
data-done={isDone || undefined}
|
||||
data-met={met || undefined}
|
||||
data-fresh={(ticked && fresh.has(key)) || undefined}
|
||||
aria-pressed={ticked}
|
||||
aria-label={chipLabel(
|
||||
`${meta(account.game).short} ${row.title}`,
|
||||
ticked,
|
||||
goal ? row.status : undefined,
|
||||
)}
|
||||
onClick={() => {
|
||||
setFresh((f) => {
|
||||
const n = new Set(f);
|
||||
if (ticked) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
toggleToday(row, today, onAction);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{/* One counter, as on live: a goal's count answers "is this week
|
||||
done?", so a streak beside it is beside the point. */}
|
||||
{goal ? (
|
||||
<span className="daily-tally">{goalCount(row.status)}</span>
|
||||
) : (
|
||||
streak > 1 && (
|
||||
<span className="daily-tally">{streak}-day streak</span>
|
||||
)
|
||||
)}
|
||||
<span className="daily-box" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="3.5" />
|
||||
<path d="m4.4 8.2 2.5 2.5 4.7-5.2" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import type { SourceHealth } from "../../../shared/feed.ts";
|
||||
import { freshness, STALE_AFTER_MS } from "../../../shared/feed.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { RefreshOutcome } from "../../api.ts";
|
||||
import { siteFor } from "../Colophon.tsx";
|
||||
|
||||
/** The Round 6 B “Data & sources” pane: when data was refreshed, and each source's state. */
|
||||
export function DataSourcesPage({
|
||||
sources,
|
||||
now,
|
||||
timezone,
|
||||
online = true,
|
||||
onRefresh,
|
||||
showUpcoming,
|
||||
detectDaily,
|
||||
standardPulls,
|
||||
onPrefs,
|
||||
}: {
|
||||
/** The sources of the games the reader tracks. */
|
||||
sources: SourceHealth[];
|
||||
now: number;
|
||||
timezone: string | null;
|
||||
online?: boolean;
|
||||
onRefresh?: () => Promise<RefreshOutcome>;
|
||||
/** What the event lists include from this data (formerly Settings → Lists). */
|
||||
showUpcoming?: boolean;
|
||||
detectDaily?: boolean;
|
||||
standardPulls?: boolean;
|
||||
onPrefs?: (patch: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const [status, setStatus] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const when = (at: string | null, never: string) =>
|
||||
at
|
||||
? `${formatInZone(at, true, timezone)} · ${formatRemaining(now - Date.parse(at))} ago`
|
||||
: never;
|
||||
const { refreshedAt, stale } = freshness(sources, now);
|
||||
const games = [...new Set(sources.map((s) => s.game))];
|
||||
const listNames = (names: string[]) =>
|
||||
names.length < 2
|
||||
? (names[0] ?? "")
|
||||
: `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
||||
const staleNames = stale.map((s) => meta(s.game).name);
|
||||
return (
|
||||
<section className="sources-page">
|
||||
<h1>Data & sources</h1>
|
||||
{onPrefs && (
|
||||
<section className="settings-group list-options">
|
||||
<h2>In your lists</h2>
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUpcoming === true}
|
||||
onChange={(e) => onPrefs({ showUpcoming: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Show events that haven't started
|
||||
<small>
|
||||
Include upcoming events in the Checklist and on the Timeline.
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={detectDaily === true}
|
||||
onChange={(e) => onPrefs({ detectDaily: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Detect daily events from source descriptions
|
||||
<small>
|
||||
Treat events whose source says they run daily as daily
|
||||
check-ins.
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={standardPulls === true}
|
||||
onChange={(e) => onPrefs({ standardPulls: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Offer standard-banner pulls in the library
|
||||
<small>Premium pulls are always offered.</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
<h2 className="sources-heading">Sources</h2>
|
||||
<p className="muted-copy">
|
||||
The source check and its last content update are separate. A recently
|
||||
checked page may still hold an older schedule. Dates are shown in{" "}
|
||||
{timezone ?? "your device's timezone"}; the source page is the
|
||||
authority, so check it before an event's final hours.
|
||||
</p>
|
||||
{onRefresh && (
|
||||
<div className="acts">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!online || checking}
|
||||
onClick={async () => {
|
||||
setChecking(true);
|
||||
const result = await onRefresh();
|
||||
setStatus(
|
||||
result === "partial"
|
||||
? "Event data checked; redeem codes could not be checked. Try again later."
|
||||
: result === "updated"
|
||||
? "Published data updated."
|
||||
: result === "same"
|
||||
? "Nothing newer has been published yet."
|
||||
: "The server could not be reached. Your saved data is still available.",
|
||||
);
|
||||
setChecking(false);
|
||||
}}
|
||||
>
|
||||
{checking ? "Checking…" : "Check for new data"}
|
||||
</button>
|
||||
<span role="status" className="help">
|
||||
{!online
|
||||
? "You're offline. This is the last downloaded event data."
|
||||
: status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="source-note">
|
||||
Event data last refreshed{" "}
|
||||
{refreshedAt ? when(refreshedAt, "") : "at an unrecorded time"}. These
|
||||
rows cover the games you have switched on.
|
||||
</p>
|
||||
{staleNames.length > 0 && (
|
||||
<p className="source-warning">
|
||||
{listNames(staleNames)}{" "}
|
||||
{staleNames.length === 1 ? "has a source" : "each have a source"} that
|
||||
has not refreshed in over two days, so end dates there may have moved.
|
||||
</p>
|
||||
)}
|
||||
{games.map((game) => (
|
||||
<div
|
||||
className="source-row"
|
||||
key={game}
|
||||
style={{ "--g": meta(game).hue } as CSSProperties}
|
||||
>
|
||||
<strong>{meta(game).name}</strong>
|
||||
<div>
|
||||
{sources
|
||||
.filter((s) => s.game === game)
|
||||
.map((s) => {
|
||||
const checked =
|
||||
[s.lastConfirmedAt, s.lastSuccessAt]
|
||||
.filter((d): d is string => d !== null)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
const reason = sourceReason(s, now);
|
||||
return (
|
||||
<div className="source-entry" key={s.sourceId}>
|
||||
{sources.filter((x) => x.game === game).length > 1 && (
|
||||
<p className="source-name">{siteFor(s.url).name}</p>
|
||||
)}
|
||||
<p>Last successful check: {when(checked, "Never")}</p>
|
||||
<p>
|
||||
Source content last changed:{" "}
|
||||
{when(
|
||||
s.contentChangedAt ?? s.lastSuccessAt,
|
||||
"Not recorded",
|
||||
)}
|
||||
</p>
|
||||
<p className={`reason${reason.current ? " current" : ""}`}>
|
||||
{reason.text}
|
||||
</p>
|
||||
<a href={s.url} target="_blank" rel="noreferrer">
|
||||
Check source page
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One sentence on a source's state, in the mockup's four cases. */
|
||||
export function sourceReason(
|
||||
s: SourceHealth,
|
||||
now: number,
|
||||
): { text: string; current: boolean } {
|
||||
const recent = (at: string | null) =>
|
||||
at !== null && now - Date.parse(at) <= STALE_AFTER_MS;
|
||||
if (s.lastSuccessAt === null && s.lastConfirmedAt === null)
|
||||
return {
|
||||
text: "No successful source fetch has been recorded. A bundled sample schedule is being held.",
|
||||
current: false,
|
||||
};
|
||||
if (recent(s.lastSuccessAt) && recent(s.contentChangedAt ?? s.lastSuccessAt))
|
||||
return {
|
||||
text: "Source check succeeded; the held schedule is recent.",
|
||||
current: true,
|
||||
};
|
||||
if (recent(s.lastConfirmedAt) || recent(s.lastSuccessAt))
|
||||
return {
|
||||
text: "Site has no new updates. The latest check succeeded, but the page has not changed.",
|
||||
current: false,
|
||||
};
|
||||
return {
|
||||
text: "Pull overdue. No recent successful source check; the cause was not reported.",
|
||||
current: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { freshness, type SourceHealth } from "../../../shared/feed.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { RefreshOutcome } from "../../api.ts";
|
||||
export function FeedFooter({
|
||||
sources,
|
||||
now,
|
||||
online,
|
||||
timezone,
|
||||
onRefresh,
|
||||
onAbout,
|
||||
}: {
|
||||
sources: SourceHealth[];
|
||||
now: number;
|
||||
online: boolean;
|
||||
timezone: string | null;
|
||||
onRefresh: () => Promise<RefreshOutcome>;
|
||||
onAbout: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const at = freshness(sources, now).refreshedAt;
|
||||
return (
|
||||
<footer className="feed-footer">
|
||||
<p>
|
||||
Event data last refreshed{" "}
|
||||
{at ? formatInZone(at, true, timezone) : "at an unrecorded time"}. Dates
|
||||
are shown in {timezone ?? "your device's timezone"}.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!online || checking}
|
||||
onClick={async () => {
|
||||
setChecking(true);
|
||||
const result = await onRefresh();
|
||||
setStatus(
|
||||
result === "partial"
|
||||
? "Event data checked; redeem codes could not be checked. Try again later."
|
||||
: result === "updated"
|
||||
? "Published data updated."
|
||||
: result === "same"
|
||||
? "Nothing newer has been published yet."
|
||||
: "The server could not be reached. Your saved data is still available.",
|
||||
);
|
||||
setChecking(false);
|
||||
}}
|
||||
>
|
||||
{checking ? "Checking…" : "Check for new data"}
|
||||
</button>
|
||||
<button type="button" onClick={onAbout}>
|
||||
About Event Clock
|
||||
</button>
|
||||
<span role="status">{status}</span>
|
||||
{!online && (
|
||||
<p>
|
||||
You're offline. This is the last downloaded event data; changes since
|
||||
then may be missing.
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { GameFocus } from "../GameFocus.tsx";
|
||||
export function FocusPicker({
|
||||
games,
|
||||
focus,
|
||||
counts,
|
||||
total,
|
||||
next,
|
||||
onFocus,
|
||||
onAdvance,
|
||||
}: ComponentProps<typeof GameFocus>) {
|
||||
const meta = useGameMeta();
|
||||
if (games.length < 2) return null;
|
||||
return (
|
||||
<section className="game-focus">
|
||||
<div className="focus-heading">
|
||||
<h2>Focus</h2>
|
||||
<button onClick={onAdvance}>
|
||||
{next === null ? "Show all games" : `Next: ${meta(next).short}`}{" "}
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="focus-pills" role="group" aria-label="Focus on one game">
|
||||
{[null, ...games].map((g) => (
|
||||
<button
|
||||
key={g ?? "all"}
|
||||
className="focus-pill"
|
||||
style={
|
||||
{ "--hue": g ? meta(g).hue : "var(--color-ink)" } as CSSProperties
|
||||
}
|
||||
aria-label={g ? meta(g).name : "All games"}
|
||||
aria-pressed={focus === g}
|
||||
data-empty={(g !== null && (counts[g] ?? 0) === 0) || undefined}
|
||||
onClick={() => onFocus(focus === g ? null : g)}
|
||||
>
|
||||
{g ? meta(g).short : "All"}
|
||||
<span className="n">{g ? (counts[g] ?? 0) : total}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** `prefs.focusBar`: where the focus bar appears. Absent means both views. */
|
||||
export function focusBarShown(
|
||||
prefs: Record<string, unknown>,
|
||||
view: string,
|
||||
): boolean {
|
||||
const bar = prefs.focusBar;
|
||||
if (typeof bar !== "object" || bar === null) return true;
|
||||
const key = view === "timeline" ? "timeline" : "checklist";
|
||||
return (bar as Record<string, unknown>)[key] !== false;
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { mintAccountId, mintLocalId } from "../../state/reader/ids.ts";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import { CodesPage } from "./CodesPage.tsx";
|
||||
import { useState } from "react";
|
||||
import { metaFor } from "../../../shared/games.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { REGION_LABEL } from "../../../shared/time.ts";
|
||||
import type { SourceHealth } from "../../../shared/feed.ts";
|
||||
import type { Region } from "../../../shared/schema.ts";
|
||||
import type {
|
||||
ReaderState,
|
||||
SaveResult,
|
||||
Account,
|
||||
TrackingIntent,
|
||||
} from "../../state/reader/schema.ts";
|
||||
import type { ReaderAction } from "../../state/reader/reducer.ts";
|
||||
import {
|
||||
createAccount,
|
||||
changeAccountRegion,
|
||||
retireAccount,
|
||||
} from "../../state/reader/accounts.ts";
|
||||
import { actionMeta, recordOf } from "../../state/reader/actions.ts";
|
||||
import { ResetTimingRow } from "./ResetTimingRow.tsx";
|
||||
import { newCodes } from "../../state/reader/codes.ts";
|
||||
import { useContext } from "react";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import { resolveReaderClock } from "../../../shared/readerClock.ts";
|
||||
import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts";
|
||||
export type ActionHandler = (action: ReaderAction) => Promise<SaveResult>;
|
||||
const hostOf = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
export function GameSettings({
|
||||
state,
|
||||
sources,
|
||||
onAction,
|
||||
codes = null,
|
||||
initialGame,
|
||||
initialTab = "setup",
|
||||
}: {
|
||||
state: ReaderState;
|
||||
sources: SourceHealth[];
|
||||
onAction: ActionHandler;
|
||||
codes?: CodesFeed | null;
|
||||
initialGame?: string | undefined;
|
||||
initialTab?: "setup" | "codes";
|
||||
}) {
|
||||
const renderedMeta = useGameMeta();
|
||||
const games = orderGames(
|
||||
[...new Set(Object.values(state.accounts).map((a) => a.game))],
|
||||
Array.isArray(state.prefs.gameOrder)
|
||||
? (state.prefs.gameOrder as string[])
|
||||
: undefined,
|
||||
(id) =>
|
||||
metaFor(id, state.customGames as Parameters<typeof metaFor>[1]).name,
|
||||
);
|
||||
const [selected] = useState(
|
||||
initialGame ??
|
||||
games.find(
|
||||
(g) =>
|
||||
!(
|
||||
Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(g)
|
||||
),
|
||||
) ??
|
||||
games[0] ??
|
||||
"",
|
||||
);
|
||||
const [tab, setTab] = useState<string>(initialTab);
|
||||
const [trackingAccount, setTrackingAccount] = useState<string | null>(null);
|
||||
const [addingServer, setAddingServer] = useState(false);
|
||||
const [server, setServer] = useState<Region>("asia");
|
||||
const [message, setMessage] = useState("");
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) => a.game === selected && !a.retiredAt,
|
||||
);
|
||||
const activeAccount =
|
||||
accounts.find((a) => a.id === trackingAccount) ?? accounts[0];
|
||||
const game = metaFor(
|
||||
selected,
|
||||
state.customGames as Parameters<typeof metaFor>[1],
|
||||
);
|
||||
const zone = useContext(DisplayTimeContext),
|
||||
now = Date.now(),
|
||||
short = renderedMeta(selected).short;
|
||||
const freshCodes = codes
|
||||
? newCodes(codes, state.codeSeen[selected] ?? [], selected).length
|
||||
: 0;
|
||||
return (
|
||||
<section className="game-settings" style={{ ["--g" as string]: game.hue }}>
|
||||
{initialGame ? (
|
||||
<div className="ptitle">
|
||||
<h1 style={{ color: renderedMeta(selected).hue }}>{game.name}</h1>
|
||||
{game.studio && <span className="studio">{game.studio}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
const hidden = Array.isArray(state.prefs.hiddenGames)
|
||||
? (state.prefs.hiddenGames as string[])
|
||||
: [];
|
||||
const following = !hidden.includes(selected);
|
||||
// Following a game with nothing tracked starts with its events.
|
||||
if (!following)
|
||||
for (const a of accounts.filter(
|
||||
(a) =>
|
||||
!a.intent.events &&
|
||||
!Object.values(a.routines).some((r) => r.followed),
|
||||
))
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: { ...a, intent: { ...a.intent, events: true } },
|
||||
...actionMeta(),
|
||||
});
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
hiddenGames: following
|
||||
? [...hidden, selected]
|
||||
: hidden.filter((g) => g !== selected),
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(selected)
|
||||
? "Follow game"
|
||||
: "Stop following game"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="subtabs">
|
||||
{["setup", "codes", "about"].map((id) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
aria-pressed={tab === id}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{id[0]!.toUpperCase() + id.slice(1)}
|
||||
{id === "codes" && freshCodes > 0 && (
|
||||
<span className="new-code">{freshCodes} new</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "about" ? (
|
||||
<div className="game-about">
|
||||
<h3 className="about-h">About {game.name}</h3>
|
||||
<div className="grp">
|
||||
{game.studio && (
|
||||
<div className="trow">
|
||||
<span>Studio</span>
|
||||
<b>{game.studio}</b>
|
||||
</div>
|
||||
)}
|
||||
{accounts.map((a) => {
|
||||
const clock = resolveReaderClock(a, Math.max(now, Date.now())),
|
||||
shift = resetShiftMs(clock.region, a.game, clock),
|
||||
next = Math.floor((now + shift) / DAY) * DAY + DAY - shift;
|
||||
return (
|
||||
<div className="trow" key={a.id}>
|
||||
<span>{REGION_LABEL[a.region]} reset</span>
|
||||
<span>
|
||||
{formatInZone(next, true, zone)} ·{" "}
|
||||
{a.clocks.at(-1)!.reset === null
|
||||
? "automatic"
|
||||
: "custom override"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<section className="grp">
|
||||
<h3>Data sources</h3>
|
||||
{sources.filter((s) => s.game === selected).length === 0 ? (
|
||||
<p className="muted-copy">No built-in source for this game.</p>
|
||||
) : (
|
||||
<>
|
||||
{sources
|
||||
.filter((s) => s.game === selected)
|
||||
.map((src) => (
|
||||
<p className="game-source" key={src.sourceId}>
|
||||
<a href={src.url} target="_blank" rel="noreferrer">
|
||||
{hostOf(src.url)}
|
||||
</a>
|
||||
<small>
|
||||
{/banner/i.test(src.sourceId)
|
||||
? "Banner schedule"
|
||||
: /pass|version|nameless|fund/i.test(src.sourceId)
|
||||
? "Battle pass schedule"
|
||||
: "Event schedule"}
|
||||
</small>
|
||||
</p>
|
||||
))}
|
||||
<p className="muted-copy">
|
||||
Thanks to the editors at{" "}
|
||||
{[
|
||||
...new Set(
|
||||
sources
|
||||
.filter((s) => s.game === selected)
|
||||
.map((src) => hostOf(src.url)),
|
||||
),
|
||||
].join(" and ")}{" "}
|
||||
for compiling and maintaining these schedules. Event Clock
|
||||
reads their work; their source pages are the authority.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
location.hash = "#settings/sources";
|
||||
}}
|
||||
>
|
||||
Source status and freshness
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
) : tab === "codes" ? (
|
||||
<CodesPage
|
||||
game={selected}
|
||||
feed={codes}
|
||||
state={state}
|
||||
onAction={onAction}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{activeAccount && (
|
||||
<section className="grp profile-tracking">
|
||||
<h3>
|
||||
What to track
|
||||
<span>
|
||||
Any combination. Each one follows or unfollows that kind of
|
||||
built-in for {short}
|
||||
{accounts.length > 1
|
||||
? ` on the ${REGION_LABEL[activeAccount.region]} server`
|
||||
: ""}
|
||||
.
|
||||
</span>
|
||||
</h3>
|
||||
{accounts.length > 1 && (
|
||||
<div className="tracking-account-choice">
|
||||
<span>Tracking for</span>
|
||||
<div className="segmented">
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
aria-pressed={a.id === activeAccount.id}
|
||||
onClick={() => setTrackingAccount(a.id)}
|
||||
>
|
||||
{REGION_LABEL[a.region]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AccountSetup
|
||||
key={activeAccount.id + "tracking"}
|
||||
account={activeAccount}
|
||||
onAction={onAction}
|
||||
onTracked={() =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
hiddenGames: (Array.isArray(state.prefs.hiddenGames)
|
||||
? state.prefs.hiddenGames
|
||||
: []
|
||||
).filter((g) => g !== selected),
|
||||
},
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="compnote">
|
||||
<span>
|
||||
Fine-tune single routines, and where they show, on the
|
||||
Routines tab.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
location.hash = "#routines";
|
||||
}}
|
||||
>
|
||||
Fine-tune in Routines →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="grp profile-servers">
|
||||
<h3>
|
||||
Servers
|
||||
<span>
|
||||
Play on more than one server? Add your other server to track
|
||||
both accounts separately.
|
||||
</span>
|
||||
</h3>
|
||||
{accounts.map((a, index) => {
|
||||
const clock = resolveReaderClock(a, Math.max(now, Date.now())),
|
||||
shift = resetShiftMs(clock.region, a.game, clock),
|
||||
next = Math.floor((now + shift) / DAY) * DAY + DAY - shift,
|
||||
reset = a.clocks.at(-1)!.reset;
|
||||
return (
|
||||
<div className="srvrow" key={a.id}>
|
||||
<span className="tagn">{index === 0 ? "Main" : "Also"}</span>
|
||||
<div className="clock">
|
||||
<b>{REGION_LABEL[a.region]}</b> ·{" "}
|
||||
{reset === null ? "automatic" : "custom"}{" "}
|
||||
{String(clock.resetHour).padStart(2, "0")}:
|
||||
{String(clock.resetMinute).padStart(2, "0")} reset ·{" "}
|
||||
{formatInZone(next, true, zone)} · in{" "}
|
||||
{formatRemaining(next - now)}
|
||||
<small>
|
||||
Separate dailies and routines for this server.
|
||||
</small>
|
||||
</div>
|
||||
<div className="r">
|
||||
<select
|
||||
aria-label={`${REGION_LABEL[a.region]} account server`}
|
||||
value={a.region}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: changeAccountRegion(
|
||||
a,
|
||||
e.target.value as Region,
|
||||
new Date().toISOString(),
|
||||
mintLocalId(),
|
||||
),
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{(["europe", "america", "asia"] as const).map((r) => (
|
||||
<option
|
||||
key={r}
|
||||
value={r}
|
||||
disabled={accounts.some(
|
||||
(other) => other.id !== a.id && other.region === r,
|
||||
)}
|
||||
>
|
||||
{REGION_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: retireAccount(a, new Date().toISOString()),
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
{REGION_LABEL[a.region]} server
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.values(state.accounts)
|
||||
.filter((a) => a.game === selected && a.retiredAt)
|
||||
.map((a) => (
|
||||
<p key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={accounts.some(
|
||||
(other) => other.region === a.region,
|
||||
)}
|
||||
title={
|
||||
accounts.some((other) => other.region === a.region)
|
||||
? "This server is already tracked. Use its active setup above."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
const resumed = { ...a };
|
||||
delete resumed.retiredAt;
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: resumed,
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Resume {REGION_LABEL[a.region]} server
|
||||
</button>
|
||||
<span className="help">
|
||||
{" "}
|
||||
· your recorded progress is kept
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn link server-add-toggle"
|
||||
aria-expanded={addingServer}
|
||||
onClick={() => setAddingServer(!addingServer)}
|
||||
>
|
||||
Track another server
|
||||
</button>
|
||||
{addingServer && (
|
||||
<section className="another-server">
|
||||
<h3>Add a server</h3>
|
||||
<p>
|
||||
Play on more than one server? Add your other server to track
|
||||
both accounts separately.
|
||||
</p>
|
||||
<p>
|
||||
You'll choose what to track again. Progress and routine pins
|
||||
start fresh.
|
||||
</p>
|
||||
<select
|
||||
aria-label="Another server"
|
||||
value={server}
|
||||
onChange={(e) => setServer(e.target.value as Region)}
|
||||
>
|
||||
{(["europe", "america", "asia"] as const).map((r) => (
|
||||
<option value={r} key={r}>
|
||||
{REGION_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (accounts.some((a) => a.region === server)) {
|
||||
setMessage(
|
||||
"You already track this server. Use its setup above.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const a = createAccount(
|
||||
selected,
|
||||
server,
|
||||
new Date().toISOString(),
|
||||
mintAccountId(),
|
||||
);
|
||||
const r = await onAction({
|
||||
type: "account",
|
||||
account: a,
|
||||
...actionMeta(),
|
||||
});
|
||||
setMessage(
|
||||
r.ok
|
||||
? "Server added. Choose what to track in its setup above."
|
||||
: "Could not save this server. Your other accounts are unchanged.",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add server
|
||||
</button>
|
||||
<p role="status">{message}</p>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
<section className="grp game-colour-group">
|
||||
<h3>
|
||||
Game colour
|
||||
<span>
|
||||
Used for {short} in every view and on all its servers.
|
||||
</span>
|
||||
</h3>
|
||||
<div className="game-colour">
|
||||
<input
|
||||
aria-label={`${game.name} colour`}
|
||||
type="color"
|
||||
value={String(
|
||||
recordOf(state.prefs.gameColors)[selected] ?? game.hue,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
gameColors: {
|
||||
...recordOf(state.prefs.gameColors),
|
||||
[selected]: e.target.value,
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
aria-label={`${game.name} hex colour`}
|
||||
type="text"
|
||||
key={
|
||||
selected + String(recordOf(state.prefs.gameColors)[selected])
|
||||
}
|
||||
defaultValue={String(
|
||||
recordOf(state.prefs.gameColors)[selected] ?? game.hue,
|
||||
)}
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
maxLength={7}
|
||||
onBlur={(e) => {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(e.target.value))
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
gameColors: {
|
||||
...recordOf(state.prefs.gameColors),
|
||||
[selected]: e.target.value,
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
else
|
||||
e.target.value = String(
|
||||
recordOf(state.prefs.gameColors)[selected] ?? game.hue,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
const colors = { ...recordOf(state.prefs.gameColors) };
|
||||
delete colors[selected];
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { gameColors: colors },
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
<span
|
||||
className="colour-preview"
|
||||
style={{ color: renderedMeta(selected).hue }}
|
||||
>
|
||||
{short} event
|
||||
</span>
|
||||
</div>
|
||||
<p className="source-note">
|
||||
The preview adjusts for readable contrast. Your selected base
|
||||
colour stays saved.
|
||||
</p>
|
||||
</section>
|
||||
{activeAccount && (
|
||||
<section className="grp profile-reset">
|
||||
<h3>
|
||||
Reset timing
|
||||
<span>
|
||||
Correct the reset time if needed; Automatic restores the
|
||||
listed clock. Earlier progress is kept.
|
||||
</span>
|
||||
</h3>
|
||||
{accounts.map((a) => (
|
||||
<ResetTimingRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
label={`${short}${accounts.length > 1 ? ` · ${REGION_LABEL[a.region]}` : ""}`}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function AccountSetup({
|
||||
account: a,
|
||||
onAction,
|
||||
onTracked,
|
||||
}: {
|
||||
account: Account;
|
||||
onAction: ActionHandler;
|
||||
onTracked: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<TrackingIntent>(a.intent);
|
||||
const builtins = routinesFor(a.game);
|
||||
const availability = {
|
||||
dailies: !a.game.startsWith("mygame:"),
|
||||
weeklyMonthly: builtins.some((r) => r.period.unit !== "cycle"),
|
||||
cycles: builtins.some((r) => r.period.unit === "cycle"),
|
||||
};
|
||||
/**
|
||||
* Saved on each click. Only the kind that changed touches its routines, so
|
||||
* turning one kind on or off never resets pins chosen for the others on the
|
||||
* Routines tab.
|
||||
*/
|
||||
const apply = async (
|
||||
next: TrackingIntent,
|
||||
force?: "dailies" | "weeklyMonthly" | "cycles",
|
||||
) => {
|
||||
const changed = (
|
||||
["events", "dailies", "weeklyMonthly", "cycles"] as const
|
||||
).filter((k) => next[k] !== draft[k] || k === force);
|
||||
setDraft(next);
|
||||
const placements = { ...a.routines };
|
||||
const place = (id: string, followed: boolean) => {
|
||||
placements[id] = { followed, checklist: followed, timeline: followed };
|
||||
};
|
||||
if (changed.includes("dailies")) place(`dailies:${a.game}`, next.dailies);
|
||||
for (const r of builtins) {
|
||||
const kind = r.period.unit === "cycle" ? "cycles" : "weeklyMonthly";
|
||||
if (changed.includes(kind)) place(r.id, next[kind]);
|
||||
}
|
||||
const result = await onAction({
|
||||
type: "account",
|
||||
account: { ...a, intent: next, routines: placements },
|
||||
...actionMeta(),
|
||||
});
|
||||
if (result.ok) onTracked();
|
||||
else setDraft(draft);
|
||||
};
|
||||
return (
|
||||
<section className="account-setup">
|
||||
<div className="comp" role="group" aria-label="What to track">
|
||||
{(
|
||||
[
|
||||
{
|
||||
id: "events",
|
||||
label: "Events",
|
||||
about: "This game's events in Checklist and Timeline",
|
||||
ids: [] as string[],
|
||||
},
|
||||
{
|
||||
id: "dailies",
|
||||
label: "Dailies",
|
||||
about:
|
||||
"Chores that reset each game-day, such as daily commissions",
|
||||
ids: availability.dailies ? [`dailies:${a.game}`] : [],
|
||||
},
|
||||
{
|
||||
id: "weeklyMonthly",
|
||||
label: "Weeklies & monthlies",
|
||||
about:
|
||||
"Weekly goals and monthly restocks, such as bosses and shop pulls",
|
||||
ids: builtins
|
||||
.filter((r) => r.period.unit !== "cycle")
|
||||
.map((r) => r.id),
|
||||
},
|
||||
{
|
||||
id: "cycles",
|
||||
label: "Cycles",
|
||||
about:
|
||||
"Routines on a game-specific schedule, such as a 14-day endgame mode",
|
||||
ids: builtins
|
||||
.filter((r) => r.period.unit === "cycle")
|
||||
.map((r) => r.id),
|
||||
},
|
||||
] as const
|
||||
).map((k) => {
|
||||
const total = k.ids.length,
|
||||
followed = k.ids.filter((id) => a.routines[id]?.followed).length;
|
||||
// Partly followed: some built-ins of this kind were fine-tuned
|
||||
// on the Routines tab. A click follows the rest.
|
||||
const some = followed > 0 && followed < total;
|
||||
const on = k.id === "events" ? draft.events : draft[k.id];
|
||||
const line =
|
||||
k.id === "events"
|
||||
? on
|
||||
? "Shown"
|
||||
: "Hidden"
|
||||
: total === 0
|
||||
? "none built in"
|
||||
: some
|
||||
? "Some, fine-tuned"
|
||||
: `${total} built in`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={k.id}
|
||||
aria-pressed={some ? "mixed" : on}
|
||||
data-st={some ? "some" : on ? "on" : "off"}
|
||||
data-empty={k.id !== "events" && total === 0}
|
||||
onClick={() =>
|
||||
k.id === "events"
|
||||
? void apply({ ...draft, events: !draft.events })
|
||||
: void apply({ ...draft, [k.id]: some ? true : !on }, k.id)
|
||||
}
|
||||
>
|
||||
<span className="cbx" aria-hidden="true">
|
||||
{on && !some && (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="ln">{k.label}</span>
|
||||
<span className="ls">{k.about}</span>
|
||||
<span className="ls">{line}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { readerDayKey } from "../../../shared/readerClock.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import type { RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { Fireworks } from "../Fireworks.tsx";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { routinePeriod } from "./TimelinePage.tsx";
|
||||
|
||||
/** The latest logged day inside the routine's current period, if any. */
|
||||
export function lastDayInPeriod(row: RoutineRow, today: string): string | null {
|
||||
const inPeriod = row.days
|
||||
.filter((d) => d >= row.status.periodStart && d <= today)
|
||||
.sort();
|
||||
return inPeriod.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned routine in the Checklist rail, drawn like a daily: a row that
|
||||
* washes in the game's colour once its target is met, with the live
|
||||
* fireworks when an action here completes it.
|
||||
*
|
||||
* - Check-ins: the box ticks today. Unticking a met routine removes the
|
||||
* period's latest tick, which may be from an earlier day.
|
||||
* - Amounts: the box completes it in one go (the amount becomes the target),
|
||||
* "+1" steps it, and the count opens an editor. Unticking a completed
|
||||
* amount puts back the value it had before, when this session saw it;
|
||||
* otherwise it opens the editor so the reader says where they are.
|
||||
*/
|
||||
export function PinnedRoutine({
|
||||
row,
|
||||
hue,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
hue: string;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const today = readerDayKey(now, row.clock);
|
||||
const amount = row.status.measure === "amount";
|
||||
const met = row.status.met;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [burst, setBurst] = useState(0);
|
||||
// The amount before a completing action, for undo.
|
||||
const [before, setBefore] = useState<number | null>(null);
|
||||
// Set by an action in this row, so only a completion made here celebrates,
|
||||
// never one that arrives with a reload or a new period.
|
||||
const acted = useRef(false);
|
||||
const wasMet = useRef(met);
|
||||
useEffect(() => {
|
||||
if (met && !wasMet.current && acted.current) setBurst((n) => n + 1);
|
||||
wasMet.current = met;
|
||||
acted.current = false;
|
||||
}, [met]);
|
||||
useEffect(() => {
|
||||
if (burst === 0) return;
|
||||
// Longer than the live strip's 1400ms: the sparks wait for the box.
|
||||
const id = setTimeout(() => setBurst(0), 1800);
|
||||
return () => clearTimeout(id);
|
||||
}, [burst]);
|
||||
|
||||
const task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id);
|
||||
const unit = task?.measure.kind === "amount" ? (task.measure.unit ?? "") : "";
|
||||
const count = row.status.count,
|
||||
required = row.status.required;
|
||||
const setAmount = (value: number) => {
|
||||
acted.current = true;
|
||||
void onAction({
|
||||
type: "amount",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
period: row.status.periodStart,
|
||||
value,
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
const setDay = (day: string, checked: boolean) => {
|
||||
acted.current = true;
|
||||
void onAction({
|
||||
type: "day",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
day,
|
||||
checked,
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
const toggle = () => {
|
||||
if (amount) {
|
||||
if (!met) {
|
||||
setBefore(count);
|
||||
setAmount(required);
|
||||
} else if (before !== null && before < required) {
|
||||
setAmount(before);
|
||||
setBefore(null);
|
||||
} else setEditing(true);
|
||||
return;
|
||||
}
|
||||
if (row.days.includes(today)) setDay(today, false);
|
||||
else if (met) {
|
||||
const last = lastDayInPeriod(row, today);
|
||||
if (last) setDay(last, false);
|
||||
} else setDay(today, true);
|
||||
};
|
||||
const step = () => {
|
||||
if (count + 1 >= required) setBefore(count);
|
||||
setAmount(count + 1);
|
||||
};
|
||||
|
||||
const period = row.cadence === "day" ? "Today" : `This ${row.cadence}`;
|
||||
const resets = formatRemaining(routinePeriod(row, now).end - now);
|
||||
const progress = amount
|
||||
? `${count.toLocaleString()} / ${required.toLocaleString()}${unit ? ` ${unit}` : ""}`
|
||||
: required > 1
|
||||
? `${Math.min(count, required)} of ${required} days`
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
className="pinned-routine"
|
||||
data-done={met || undefined}
|
||||
data-burst={burst > 0 || undefined}
|
||||
style={{ "--g": hue } as CSSProperties}
|
||||
>
|
||||
{burst > 0 && <Fireworks key={burst} />}
|
||||
<div className="pr-text">
|
||||
<strong>{row.title}</strong>
|
||||
<small>
|
||||
{met ? `Done ${lower(period)}` : period} · resets in {resets}
|
||||
</small>
|
||||
{amount && editing ? (
|
||||
<AmountEditor
|
||||
row={row}
|
||||
unit={unit}
|
||||
onSave={(value) => {
|
||||
if (value >= required && count < required) setBefore(count);
|
||||
setAmount(value);
|
||||
setEditing(false);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
) : amount ? (
|
||||
<span className="pr-amount">
|
||||
<button
|
||||
type="button"
|
||||
className="pr-count"
|
||||
title="Enter an amount"
|
||||
aria-label={`${row.title}: ${progress}. Enter an amount`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span className="pr-bar" aria-hidden="true">
|
||||
<i
|
||||
style={
|
||||
{
|
||||
width: `${Math.min(1, count / required) * 100}%`,
|
||||
"--from": `${Math.min(1, (before ?? 0) / required) * 100}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{progress}
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10.5 2.5l3 3L6 13H3v-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
{!met && (
|
||||
<button
|
||||
type="button"
|
||||
className="pr-step"
|
||||
aria-label={`Add one to ${row.title}`}
|
||||
onClick={step}
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
progress && <span className="pr-days">{progress}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="pr-box"
|
||||
aria-pressed={met}
|
||||
aria-label={
|
||||
met
|
||||
? `${row.title}, done ${lower(period)}. Undo`
|
||||
: amount
|
||||
? `Mark ${row.title} complete`
|
||||
: `${row.title}, not done ${lower(period)}. Tick today`
|
||||
}
|
||||
title={met ? "Undo" : amount ? "Mark complete" : "Tick today"}
|
||||
onClick={toggle}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="3.5" />
|
||||
<path d="m4.4 8.2 2.5 2.5 4.7-5.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How long typing must pause before a value at the target completes it. */
|
||||
export const AUTO_COMPLETE_MS = 450;
|
||||
|
||||
function AmountEditor({
|
||||
row,
|
||||
unit,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
unit: string;
|
||||
onSave: (value: number) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(String(row.status.count));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
input.current?.select();
|
||||
}, []);
|
||||
// A number at or over the target completes it without Save. The pause lets
|
||||
// a multi-digit number ("10000") finish before it counts.
|
||||
useEffect(() => {
|
||||
const n = Number(value);
|
||||
if (
|
||||
value === "" ||
|
||||
!Number.isInteger(n) ||
|
||||
n < row.status.required ||
|
||||
n === row.status.count
|
||||
)
|
||||
return;
|
||||
const id = setTimeout(() => onSave(n), AUTO_COMPLETE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [value]);
|
||||
return (
|
||||
<form
|
||||
className="pr-edit"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const n = Number(value);
|
||||
if (Number.isInteger(n) && n >= 0 && n !== row.status.count) onSave(n);
|
||||
else onCancel();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null))
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={input}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
max="1000000000"
|
||||
inputMode="numeric"
|
||||
aria-label={`${row.title} amount`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
/ {row.status.required.toLocaleString()}
|
||||
{unit && ` ${unit}`}
|
||||
</span>
|
||||
<button type="submit" className="btn sm">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** "This week" → "this week", for the middle of a sentence. */
|
||||
function lower(period: string): string {
|
||||
return period[0]!.toLowerCase() + period.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useContext, type CSSProperties } from "react";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { mintLocalId } from "../../state/reader/ids.ts";
|
||||
import type { Account } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import {
|
||||
appendClockRevision,
|
||||
resolveReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import {
|
||||
DAY,
|
||||
formatRemaining,
|
||||
REGION_LABEL,
|
||||
resetShiftMs,
|
||||
} from "../../../shared/time.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
|
||||
const hh = (h: number, m = 0) =>
|
||||
`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
|
||||
/**
|
||||
* One server's reset (Round 6 B `.reset-editor`): its clock, when that is in
|
||||
* your time and how long until it, and a choice of Automatic or a whole hour.
|
||||
* A change appends a clock revision, so earlier progress is kept.
|
||||
*/
|
||||
export function ResetTimingRow({
|
||||
account: a,
|
||||
label,
|
||||
now,
|
||||
onAction,
|
||||
}: {
|
||||
account: Account;
|
||||
/** "Genshin" or "Genshin · Europe". */
|
||||
label: string;
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
}) {
|
||||
const meta = useGameMeta(),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
// A just-saved revision is stamped with the real time, which can be up to a
|
||||
// tick past `now`: resolve at whichever is later.
|
||||
const clock = resolveReaderClock(a, Math.max(now, Date.now())),
|
||||
current = a.clocks.at(-1)!,
|
||||
shift = resetShiftMs(clock.region, a.game, clock),
|
||||
next = Math.floor((now + shift) / DAY) * DAY + DAY - shift;
|
||||
const value =
|
||||
current.reset === null
|
||||
? "automatic"
|
||||
: `${current.reset.hour}:${current.reset.minute}`;
|
||||
const setReset = (reset: { hour: number; minute: number } | null) =>
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: appendClockRevision(
|
||||
a,
|
||||
reset,
|
||||
a.region,
|
||||
new Date().toISOString(),
|
||||
mintLocalId(),
|
||||
),
|
||||
...actionMeta(),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="reset-row"
|
||||
style={{ "--g": meta(a.game).hue } as CSSProperties}
|
||||
>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<small>
|
||||
{current.reset === null ? "Automatic" : "Custom"}:{" "}
|
||||
{hh(clock.resetHour, clock.resetMinute)} {REGION_LABEL[a.region]}{" "}
|
||||
server time · {formatInZone(next, true, zone)} · in{" "}
|
||||
{formatRemaining(next - now)}
|
||||
</small>
|
||||
</div>
|
||||
<label>
|
||||
<span>Reset timing</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "automatic") setReset(null);
|
||||
else {
|
||||
const [hour, minute] = e.target.value.split(":").map(Number);
|
||||
setReset({ hour: hour!, minute: minute! });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="automatic">
|
||||
Automatic (
|
||||
{hh(current.automaticBase.hour, current.automaticBase.minute)})
|
||||
</option>
|
||||
{current.reset !== null && current.reset.minute !== 0 && (
|
||||
<option value={value}>
|
||||
{hh(current.reset.hour, current.reset.minute)} server time
|
||||
</option>
|
||||
)}
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<option key={h} value={`${h}:0`}>
|
||||
{hh(h)} server time
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Where a step takes the reader; ReaderApp opens the matching page. */
|
||||
export type TourStop =
|
||||
"routines" | "soon" | "timeline" | "codes" | "archive" | "settings";
|
||||
|
||||
export const TOUR_STEPS: readonly {
|
||||
stop: TourStop;
|
||||
title: string;
|
||||
body: string;
|
||||
}[] = [
|
||||
{
|
||||
stop: "routines",
|
||||
title: "Your routines have a home",
|
||||
body: "Follow your dailies, weeklies and monthlies in the Routines tab, and pin the ones you want beside your Checklist or on the Timeline.",
|
||||
},
|
||||
{
|
||||
stop: "soon",
|
||||
title: "Your Checklist",
|
||||
body: "What ends first, at the top. Focus on a game, tick today's dailies and pinned routines beside the list, and switch between Standard and Compact rows.",
|
||||
},
|
||||
{
|
||||
stop: "timeline",
|
||||
title: "The Timeline",
|
||||
body: "Every event as a bar across the calendar, with now marked. Show time left or end dates, and choose its look in Settings → Appearance.",
|
||||
},
|
||||
{
|
||||
stop: "codes",
|
||||
title: "Redeem codes",
|
||||
body: "New codes for the games you track show up at the top of the Checklist. Each game's Codes tab lists them with their rewards and expiry, ready to copy, and remembers which you've redeemed.",
|
||||
},
|
||||
{
|
||||
stop: "archive",
|
||||
title: "Your Archive",
|
||||
body: "Done and ignored events move here with their details and progress. Restore any of them at any time.",
|
||||
},
|
||||
{
|
||||
stop: "settings",
|
||||
title: "Your settings, together",
|
||||
body: "Appearance, region and time, each game's servers and colours, data sources and backups. The changelog has its own page here too.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The returning reader's introduction to the new look. Continue always sits
|
||||
* first, in the same place, so clicking through never lands on Back; Back
|
||||
* follows it, and Skip stands apart at the end.
|
||||
*/
|
||||
export function ReturningTour({
|
||||
onStep,
|
||||
onFinish,
|
||||
autoStart = false,
|
||||
}: {
|
||||
onStep: (stop: TourStop) => void;
|
||||
onFinish: () => void;
|
||||
/** Replayed from Settings: skip the welcome and open the first step. */
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [step, setStep] = useState(autoStart ? 0 : -1);
|
||||
const go = (next: number) => {
|
||||
setStep(next);
|
||||
onStep(TOUR_STEPS[next]!.stop);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoStart) onStep(TOUR_STEPS[0]!.stop);
|
||||
}, []);
|
||||
const last = step === TOUR_STEPS.length - 1;
|
||||
return (
|
||||
<aside className="returning-tour" aria-label="Introduction to the new look">
|
||||
{step >= 0 && (
|
||||
<p className="tour-step">
|
||||
{step + 1} of {TOUR_STEPS.length}
|
||||
</p>
|
||||
)}
|
||||
<h2>
|
||||
{step < 0
|
||||
? "Welcome back. A little has moved."
|
||||
: TOUR_STEPS[step]!.title}
|
||||
</h2>
|
||||
<p>
|
||||
{step < 0
|
||||
? "Your games, routines and progress are all still here. Take a short tour of the new layout, or keep browsing."
|
||||
: TOUR_STEPS[step]!.body}
|
||||
</p>
|
||||
<div className="tour-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="commit"
|
||||
onClick={() => (last ? onFinish() : go(step + 1))}
|
||||
>
|
||||
{step < 0 ? "Take the quick tour" : last ? "Finish tour" : "Continue"}
|
||||
</button>
|
||||
{step > 0 && (
|
||||
<button type="button" className="btn" onClick={() => go(step - 1)}>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="tour-skip" onClick={onFinish}>
|
||||
{step < 0 ? "Keep browsing" : "Skip tour"}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Fireworks } from "../Fireworks.tsx";
|
||||
import { useSectionCelebration } from "./useSectionCelebration.ts";
|
||||
|
||||
export function RoutineGroup({
|
||||
title,
|
||||
policy,
|
||||
focused,
|
||||
children,
|
||||
hue,
|
||||
summary,
|
||||
done,
|
||||
total,
|
||||
}: {
|
||||
title: string;
|
||||
hue?: string;
|
||||
summary?: string;
|
||||
policy: "collapsed" | "expanded" | "always";
|
||||
focused: boolean;
|
||||
children: ReactNode;
|
||||
/** Met routines in the group; with `total`, finishing them all celebrates. */
|
||||
done?: number;
|
||||
total?: number;
|
||||
}) {
|
||||
const burst = useSectionCelebration(total ?? 0, done ?? 0);
|
||||
const complete = total !== undefined && total > 0 && done === total;
|
||||
const shared = {
|
||||
className: "routine-group",
|
||||
style: { color: hue },
|
||||
"data-complete": complete || undefined,
|
||||
"data-celebrate": burst > 0 || undefined,
|
||||
};
|
||||
const celebration = burst > 0 && <Fireworks key={burst} grand hue={hue} />;
|
||||
return policy === "always" ? (
|
||||
<section {...shared}>
|
||||
{celebration}
|
||||
<h3>
|
||||
{title}
|
||||
<small>{summary}</small>
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
) : (
|
||||
<details {...shared} open={focused || policy === "expanded"}>
|
||||
<summary>
|
||||
{title}
|
||||
<small>{summary}</small>
|
||||
</summary>
|
||||
{celebration}
|
||||
{children}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { readerDayKey } from "../../../shared/readerClock.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import type { RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
|
||||
/**
|
||||
* A routine's progress as the Routines table shows it: the Round 6 `.tick` for
|
||||
* check-ins and the `.amt` bar for amounts. An amount reads as text until it is
|
||||
* clicked, then becomes a number field, so the table is not a wall of inputs.
|
||||
*/
|
||||
export function RoutineProgress({
|
||||
row,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
if (row.status.measure !== "amount") {
|
||||
const today = readerDayKey(now, row.clock),
|
||||
done = row.days.includes(today),
|
||||
many = row.status.required > 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="tick"
|
||||
aria-pressed={many ? row.status.met : done}
|
||||
aria-label={`${row.title}, ${done ? "done" : "not done"} today`}
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "day",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
day: today,
|
||||
checked: !done,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="box" aria-hidden="true">
|
||||
<Check />
|
||||
</span>
|
||||
{many &&
|
||||
`${Math.min(row.status.count, row.status.required)} of ${row.status.required} days`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id),
|
||||
unit = task?.measure.kind === "amount" ? (task.measure.unit ?? "") : "";
|
||||
const fill = Math.min(1, row.status.count / row.status.required);
|
||||
if (editing)
|
||||
return (
|
||||
<AmountForm
|
||||
row={row}
|
||||
unit={unit}
|
||||
onAction={onAction}
|
||||
onDone={() => setEditing(false)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="amt"
|
||||
title="Enter an amount"
|
||||
aria-label={`${row.title}: ${row.status.count.toLocaleString()} of ${row.status.required.toLocaleString()}${unit ? ` ${unit}` : ""}. Enter an amount`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span className={`bar${fill >= 1 ? " full" : ""}`} aria-hidden="true">
|
||||
<i style={{ width: `${fill * 100}%` }} />
|
||||
</span>
|
||||
<span className="num">
|
||||
<b>{row.status.count.toLocaleString()}</b> /{" "}
|
||||
{row.status.required.toLocaleString()}
|
||||
{unit && ` ${unit}`}
|
||||
</span>
|
||||
<svg className="edit" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10.5 2.5l3 3L6 13H3v-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AmountForm({
|
||||
row,
|
||||
unit,
|
||||
onAction,
|
||||
onDone,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
unit: string;
|
||||
onAction: ActionHandler;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(String(row.status.count));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
input.current?.select();
|
||||
}, []);
|
||||
const save = () => {
|
||||
const n = Number(value);
|
||||
if (Number.isInteger(n) && n >= 0 && n !== row.status.count)
|
||||
void onAction({
|
||||
type: "amount",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
period: row.status.periodStart,
|
||||
value: n,
|
||||
...actionMeta(),
|
||||
});
|
||||
onDone();
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="amt editing"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) onDone();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={input}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
max="1000000000"
|
||||
inputMode="numeric"
|
||||
aria-label={`${row.title} amount`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onDone();
|
||||
}}
|
||||
/>
|
||||
<span className="num">
|
||||
/ {row.status.required.toLocaleString()}
|
||||
{unit && ` ${unit}`}
|
||||
</span>
|
||||
<button type="submit" className="btn sm">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { routinePeriod } from "./TimelinePage.tsx";
|
||||
import { manyServers, serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { CSSProperties } from "react";
|
||||
import { RoutineHistory } from "./RoutineHistory.tsx";
|
||||
import { useState } from "react";
|
||||
|
||||
import { routinesFor, mineFrom } from "../../../shared/routines.ts";
|
||||
import { mintTaskId, type Task } from "../../../shared/task.ts";
|
||||
import {
|
||||
RoutineDrawer,
|
||||
type CreatePlacement,
|
||||
type DrawerPane,
|
||||
} from "./RoutineDrawer.tsx";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ReaderState, Account } from "../../state/reader/schema.ts";
|
||||
import { routineRows, type RoutineRow } from "../../state/reader/routines.ts";
|
||||
import type { TaskDraft } from "../../state/useTasks.ts";
|
||||
import { RoutineProgress } from "./RoutineProgress.tsx";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
// One account write per bulk action prevents a later row from restoring an earlier pin.
|
||||
export function placementAccounts(
|
||||
state: ReaderState,
|
||||
rows: readonly RoutineRow[],
|
||||
key: "checklist" | "timeline",
|
||||
value: boolean,
|
||||
): Account[] {
|
||||
const accounts = new Map<string, Account>();
|
||||
for (const row of rows) {
|
||||
const account =
|
||||
accounts.get(row.accountId) ?? state.accounts[row.accountId]!;
|
||||
accounts.set(row.accountId, {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
[row.id]: { ...row.placement, [key]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...accounts.values()];
|
||||
}
|
||||
export function ChecklistDisplay({
|
||||
state,
|
||||
onAction,
|
||||
plain = false,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
/** Just the two fields, for a settings group; the Routines page shows them as a band. */
|
||||
plain?: boolean;
|
||||
}) {
|
||||
const fields = (
|
||||
<>
|
||||
<label>
|
||||
Dailies on Checklist
|
||||
<select
|
||||
value={String(state.prefs.dailyLayout ?? "separate")}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { dailyLayout: e.target.value },
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="separate">Separate panel</option>
|
||||
<option value="grouped">Grouped with routines</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Routine groups
|
||||
<select
|
||||
value={String(state.prefs.routineDisclosure ?? "collapsed")}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { routineDisclosure: e.target.value },
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="collapsed">Collapsed by default</option>
|
||||
<option value="expanded">Expanded by default</option>
|
||||
<option value="always">Always expanded</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
if (plain) return fields;
|
||||
return (
|
||||
<fieldset className="checklist-display">
|
||||
<legend className="sr-only">Checklist display</legend>
|
||||
{fields}
|
||||
<p className="help">
|
||||
Focusing a game opens its routines automatically. Pins below decide
|
||||
which routines appear on your Checklist.
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
export function RoutinesPage({
|
||||
state,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const [drawer, setDrawer] = useState<DrawerPane | null>(null);
|
||||
const [editing, setEditing] = useState<Task | null>(null);
|
||||
const open = (pane: DrawerPane, task: Task | null = null) => {
|
||||
setEditing(task);
|
||||
setDrawer(pane);
|
||||
};
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!(
|
||||
Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(a.game)
|
||||
),
|
||||
);
|
||||
const save = async (draft: TaskDraft, placement: CreatePlacement) => {
|
||||
const at = new Date().toISOString(),
|
||||
id = editing ? editing.id : mintTaskId();
|
||||
const task = {
|
||||
...(editing ?? {}),
|
||||
...draft,
|
||||
id,
|
||||
at: editing ? editing.at : at,
|
||||
updatedAt: at,
|
||||
};
|
||||
const result = await onAction({
|
||||
type: "record",
|
||||
collection: "tasks",
|
||||
id,
|
||||
value: task,
|
||||
...actionMeta(),
|
||||
});
|
||||
if (!result.ok) return false;
|
||||
// An edit keeps where the routine already shows; a new one follows on the
|
||||
// chosen server with the chosen pins.
|
||||
if (editing) return true;
|
||||
const a = state.accounts[placement.accountId];
|
||||
if (!a) return false;
|
||||
const placed = await onAction({
|
||||
type: "account",
|
||||
account: {
|
||||
...a,
|
||||
routines: {
|
||||
...a.routines,
|
||||
[id]: {
|
||||
followed: true,
|
||||
checklist: placement.checklist,
|
||||
timeline: placement.timeline,
|
||||
},
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
return placed.ok;
|
||||
};
|
||||
const copy = async (row: RoutineRow) => {
|
||||
const routine = routinesFor(row.game).find((r) => r.id === row.id);
|
||||
if (!routine) return;
|
||||
const at = new Date().toISOString(),
|
||||
id = mintTaskId();
|
||||
const account = state.accounts[row.accountId]!;
|
||||
const result = await onAction({
|
||||
type: "record",
|
||||
collection: "tasks",
|
||||
id,
|
||||
value: { ...mineFrom(routine), id, at, updatedAt: at },
|
||||
...actionMeta(),
|
||||
});
|
||||
if (!result.ok) return;
|
||||
for (const [revision, items] of Object.entries(
|
||||
state.data[account.id]!.daily,
|
||||
)) {
|
||||
const log = items[row.id] as { days?: string[] } | undefined;
|
||||
for (const day of log?.days ?? [])
|
||||
await onAction({
|
||||
type: "day",
|
||||
accountId: account.id,
|
||||
revisionId: revision,
|
||||
itemId: id,
|
||||
day,
|
||||
checked: true,
|
||||
...actionMeta(),
|
||||
});
|
||||
}
|
||||
for (const [revision, items] of Object.entries(
|
||||
state.data[account.id]!.amounts,
|
||||
)) {
|
||||
const log = items[row.id] as
|
||||
Record<string, { value: number }> | undefined;
|
||||
for (const [period, value] of Object.entries(log ?? {}))
|
||||
await onAction({
|
||||
type: "amount",
|
||||
accountId: account.id,
|
||||
revisionId: revision,
|
||||
itemId: id,
|
||||
period,
|
||||
value: value.value,
|
||||
...actionMeta(),
|
||||
});
|
||||
}
|
||||
await onAction({
|
||||
type: "account",
|
||||
account: {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
[id]: row.placement,
|
||||
[row.id]: { ...row.placement, followed: false },
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
const group = state.prefs.routineGroup === "game" ? "game" : "reset";
|
||||
const allRows = accounts
|
||||
.flatMap((a) => routineRows(state, a.id, now))
|
||||
.filter((r) => r.placement.followed);
|
||||
const bands =
|
||||
group === "game"
|
||||
? accounts.map((a) => ({
|
||||
id: a.id,
|
||||
title: meta(a.game).name,
|
||||
rows: allRows.filter((r) => r.accountId === a.id),
|
||||
}))
|
||||
: (["day", "week", "month", "cycle"] as const).map((c) => ({
|
||||
id: c,
|
||||
title: {
|
||||
day: "Dailies",
|
||||
week: "Weeklies",
|
||||
month: "Monthlies",
|
||||
cycle: "Cycles",
|
||||
}[c],
|
||||
rows: allRows.filter((r) => r.cadence === c),
|
||||
}));
|
||||
return (
|
||||
<section className="routines-page">
|
||||
<div className="rt-top">
|
||||
<h1>Routines</h1>
|
||||
<p className="sum">
|
||||
{`${allRows.filter((r) => !r.status.met).length} of ${allRows.length} still to do. ${allRows.filter((r) => r.placement.checklist).length} pinned to the checklist, ${allRows.filter((r) => r.placement.timeline && r.cadence !== "day").length} to the timeline. The rest live only here.`}
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{(["reset", "game"] as const).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
aria-pressed={g === group}
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { routineGroup: g },
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{g === "reset" ? "By reset" : "By game"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn" onClick={() => open("library")}>
|
||||
Browse the library
|
||||
</button>
|
||||
<button type="button" className="commit" onClick={() => open("create")}>
|
||||
Create your own routine
|
||||
</button>
|
||||
</div>
|
||||
{drawer && (
|
||||
<RoutineDrawer
|
||||
state={state}
|
||||
now={now}
|
||||
pane={drawer}
|
||||
editing={editing}
|
||||
onPane={(pane) => open(pane)}
|
||||
onClose={() => {
|
||||
setDrawer(null);
|
||||
setEditing(null);
|
||||
}}
|
||||
onAction={onAction}
|
||||
onSave={save}
|
||||
/>
|
||||
)}
|
||||
<ChecklistDisplay state={state} onAction={onAction} />
|
||||
{bands.map((band) => {
|
||||
const rows = band.rows;
|
||||
if (!rows.length) return null;
|
||||
const setPlacement = (
|
||||
row: RoutineRow,
|
||||
key: "followed" | "checklist" | "timeline",
|
||||
value: boolean,
|
||||
) => {
|
||||
const a = state.accounts[row.accountId]!;
|
||||
return onAction({
|
||||
type: "account",
|
||||
account: {
|
||||
...a,
|
||||
routines: {
|
||||
...a.routines,
|
||||
[row.id]: { ...row.placement, [key]: value },
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<section className="band" key={band.id}>
|
||||
<div className="band-h">
|
||||
<div>
|
||||
<h2>{band.title}</h2>
|
||||
<p className="meta">
|
||||
{
|
||||
rows.filter((r) => r.placement.followed && !r.status.met)
|
||||
.length
|
||||
}{" "}
|
||||
of {rows.length} still to do
|
||||
</p>
|
||||
<div className="bulk-pins">
|
||||
{(["checklist", "timeline"] as const)
|
||||
.filter(
|
||||
(key) =>
|
||||
key !== "timeline" ||
|
||||
rows.some((r) => r.cadence !== "day"),
|
||||
)
|
||||
.map((key) => (
|
||||
<button
|
||||
className="btn"
|
||||
key={key}
|
||||
onClick={async () => {
|
||||
const value = !rows.every((r) => r.placement[key]);
|
||||
for (const account of placementAccounts(
|
||||
state,
|
||||
rows,
|
||||
key,
|
||||
value,
|
||||
))
|
||||
await onAction({
|
||||
type: "account",
|
||||
account,
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{rows.every((r) => r.placement[key])
|
||||
? "Unpin all from"
|
||||
: "Pin all to"}{" "}
|
||||
{key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="band-reset">
|
||||
<small>Next reset in</small>
|
||||
<strong>
|
||||
{formatRemaining(
|
||||
Math.min(...rows.map((r) => routinePeriod(r, now).end)) -
|
||||
now,
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<table className="rt">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="h-g">Game</th>
|
||||
<th className="h-t">Routine</th>
|
||||
<th className="h-prog">Progress</th>
|
||||
{(["checklist", "timeline"] as const).map((key) => (
|
||||
<th className="c-pin" key={key}>
|
||||
<span className="pinlbl">
|
||||
<PinIcon kind={key} on={false} />
|
||||
<span>
|
||||
{key === "checklist" ? "Checklist" : "Timeline"}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="c-k">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.accountId + row.id}
|
||||
className={row.status.met ? "met" : ""}
|
||||
style={{ "--g": meta(row.game).hue } as CSSProperties}
|
||||
>
|
||||
<td>
|
||||
<span className="gcell">
|
||||
<span>
|
||||
{meta(row.game).short}
|
||||
{manyServers(state, row.game) && (
|
||||
<small>
|
||||
{state.accounts[row.accountId]?.region}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="rtitle">
|
||||
<span className="gsm">
|
||||
{meta(row.game).short}
|
||||
{serverSuffix(
|
||||
state,
|
||||
row.game,
|
||||
state.accounts[row.accountId]?.region,
|
||||
)}
|
||||
</span>
|
||||
{row.cadence === "day" && row.id.startsWith("dailies:")
|
||||
? "Dailies: "
|
||||
: ""}
|
||||
{row.title}
|
||||
{row.task?.note && (
|
||||
<small className="routine-note">{row.task.note}</small>
|
||||
)}
|
||||
{group === "game" && (
|
||||
<small className="routine-cadence">
|
||||
{row.cadence === "day"
|
||||
? "Daily"
|
||||
: row.cadence === "week"
|
||||
? "Weekly"
|
||||
: row.cadence === "month"
|
||||
? "Monthly"
|
||||
: "Game cycle"}
|
||||
</small>
|
||||
)}
|
||||
</td>
|
||||
<td className="prog">
|
||||
<RoutineProgress
|
||||
row={row}
|
||||
onAction={onAction}
|
||||
now={now}
|
||||
/>
|
||||
</td>
|
||||
{(["checklist", "timeline"] as const).map((key) => (
|
||||
<td className="c-pin" key={key}>
|
||||
{key === "timeline" && row.cadence === "day" ? (
|
||||
<span
|
||||
className="no-pin"
|
||||
title="Dailies stay on the Checklist"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="pin"
|
||||
aria-label={`${row.placement[key] ? "Unpin from" : "Pin to"} ${key === "checklist" ? "Checklist" : "Timeline"}: ${row.title}`}
|
||||
aria-pressed={row.placement[key]}
|
||||
title={`${row.placement[key] ? "Shown in" : "Show in"} ${key === "checklist" ? "Checklist" : "Timeline"}`}
|
||||
onClick={() =>
|
||||
setPlacement(row, key, !row.placement[key])
|
||||
}
|
||||
>
|
||||
<PinIcon kind={key} on={row.placement[key]} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="c-k">
|
||||
<details className="routine-menu">
|
||||
<summary
|
||||
className="kebab"
|
||||
aria-label={`More for ${row.title}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="5" cy="12" r="1.7" />
|
||||
<circle cx="12" cy="12" r="1.7" />
|
||||
<circle cx="19" cy="12" r="1.7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div>
|
||||
{row.source && (
|
||||
<a
|
||||
href={row.source}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
{row.id.startsWith("routine:") ? (
|
||||
<button onClick={() => void copy(row)}>
|
||||
Make it mine
|
||||
</button>
|
||||
) : (
|
||||
row.task && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => open("create", row.task)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "record",
|
||||
collection: "tasks",
|
||||
id: row.id,
|
||||
value: null,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete routine
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
setPlacement(
|
||||
row,
|
||||
"followed",
|
||||
!row.placement.followed,
|
||||
)
|
||||
}
|
||||
>
|
||||
{row.placement.followed
|
||||
? "Stop following"
|
||||
: "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<details className="routine-histories">
|
||||
<summary>Catch up & recorded history</summary>
|
||||
{accounts.map((a) => (
|
||||
<RoutineHistory
|
||||
key={a.id}
|
||||
account={a}
|
||||
state={state}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</details>
|
||||
{!accounts.some((a) =>
|
||||
routineRows(state, a.id, now).some((r) => r.placement.followed),
|
||||
) && (
|
||||
<p className="empty-state">
|
||||
No routines followed yet. Browse the library to choose your dailies,
|
||||
weekly tasks and game cycles.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Round 6 pin glyphs: a list for the Checklist, staggered bars for the Timeline. */
|
||||
function PinIcon({ kind }: { kind: "checklist" | "timeline"; on?: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{kind === "checklist" ? (
|
||||
<>
|
||||
<path d="M9 6h11M9 12h11M9 18h11" />
|
||||
<path d="M4 6h.01M4 12h.01M4 18h.01" strokeWidth="3" />
|
||||
</>
|
||||
) : (
|
||||
<path d="M4 6h9M8 12h12M4 18h7" strokeWidth="2.2" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import { useState, type ReactNode } from "react";
|
||||
export type SettingsPage =
|
||||
| "own"
|
||||
| "games"
|
||||
| "appearance"
|
||||
| "time"
|
||||
| "sources"
|
||||
| "backup"
|
||||
| "about"
|
||||
| "changelog";
|
||||
export const SETTINGS_PAGES: readonly [SettingsPage, string][] = [
|
||||
["about", "About Event Clock"],
|
||||
["changelog", "Changelog"],
|
||||
["sources", "Data & sources"],
|
||||
["time", "Region & time"],
|
||||
["appearance", "Appearance"],
|
||||
["own", "Your own events and games"],
|
||||
["backup", "Backup"],
|
||||
];
|
||||
export function SettingsLayout({
|
||||
page,
|
||||
onPage,
|
||||
children,
|
||||
state,
|
||||
game,
|
||||
onGame,
|
||||
}: {
|
||||
state?: ReaderState;
|
||||
game?: string | undefined;
|
||||
onGame?: (game: string) => void;
|
||||
page: SettingsPage;
|
||||
onPage: (page: SettingsPage) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const [allGames, setAllGames] = useState(false);
|
||||
const games = state
|
||||
? orderGames(
|
||||
[
|
||||
...new Set(
|
||||
Object.values(state.accounts)
|
||||
.filter((a) => !a.retiredAt)
|
||||
.map((a) => a.game),
|
||||
),
|
||||
],
|
||||
Array.isArray(state.prefs.gameOrder)
|
||||
? (state.prefs.gameOrder as string[])
|
||||
: undefined,
|
||||
(g) => meta(g).name,
|
||||
)
|
||||
: [];
|
||||
const isHidden = (g: string) =>
|
||||
Array.isArray(state?.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(g);
|
||||
games.sort((a, b) => Number(isHidden(a)) - Number(isHidden(b)));
|
||||
const descriptions: Partial<Record<SettingsPage, string>> = {
|
||||
own: "Events and games you added",
|
||||
appearance: "Theme, colours & default view",
|
||||
time: "Server defaults & display timezone",
|
||||
sources: "Freshness, sources & list options",
|
||||
backup: "Save, export & restore",
|
||||
about: "Credits & acknowledgements",
|
||||
changelog: "What changed",
|
||||
};
|
||||
const chevron = (
|
||||
<svg
|
||||
className="settings-chevron"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 4 4 4-4 4" />
|
||||
</svg>
|
||||
);
|
||||
const gameButton = (g: string) => {
|
||||
const accounts = Object.values(state!.accounts).filter(
|
||||
(a) => a.game === g && !a.retiredAt,
|
||||
);
|
||||
const parts = [
|
||||
["events", "events"],
|
||||
["dailies", "dailies"],
|
||||
["weeklyMonthly", "weeklies"],
|
||||
["cycles", "cycles"],
|
||||
] as const;
|
||||
const summary = isHidden(g)
|
||||
? "Not followed"
|
||||
: parts
|
||||
.filter(([key]) => accounts.some((a) => a.intent[key]))
|
||||
.map(([, label]) => label)
|
||||
.join(", ") || "Nothing tracked";
|
||||
return (
|
||||
<button
|
||||
key={g}
|
||||
className="settings-row"
|
||||
data-off={isHidden(g) || undefined}
|
||||
aria-current={page === "games" && game === g ? "page" : undefined}
|
||||
onClick={() => onGame?.(g)}
|
||||
style={{ ["--g" as string]: meta(g).hue }}
|
||||
>
|
||||
<span className="settings-marker" aria-hidden="true" />
|
||||
<span className="settings-copy">
|
||||
<span>{meta(g).name}</span>
|
||||
<small>
|
||||
{summary}
|
||||
{!isHidden(g) && accounts.length > 1
|
||||
? ` · ${accounts.map((a) => a.region).join(" + ")}`
|
||||
: ""}
|
||||
</small>
|
||||
</span>
|
||||
{chevron}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const others = games.filter(isHidden);
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
<nav aria-label="Settings pages">
|
||||
<h2>General</h2>
|
||||
{SETTINGS_PAGES.map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
className="settings-row"
|
||||
aria-current={id === page && !game ? "page" : undefined}
|
||||
onClick={() => onPage(id)}
|
||||
>
|
||||
<span className="settings-marker" aria-hidden="true" />
|
||||
<span className="settings-copy">
|
||||
<span>{label}</span>
|
||||
<small>{descriptions[id]}</small>
|
||||
</span>
|
||||
{chevron}
|
||||
</button>
|
||||
))}
|
||||
<p className="settings-games-label">Your games</p>
|
||||
{games.filter((g) => !isHidden(g)).map(gameButton)}
|
||||
{others.length > 0 && (
|
||||
<>
|
||||
<p className="settings-games-label">Other games</p>
|
||||
{(allGames ? others : others.slice(0, 3)).map(gameButton)}
|
||||
{!allGames && others.length > 3 && (
|
||||
<button onClick={() => setAllGames(true)}>
|
||||
Show {others.length - 3} more games
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<section className="settings-content">{children}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { TimezoneSelect } from "./TimezoneSelect.tsx";
|
||||
import { ViewCards } from "./ViewCards.tsx";
|
||||
import { GAMES, metaFor, type GameMeta } from "../../../shared/games.ts";
|
||||
import { offered, routinesFor } from "../../../shared/routines.ts";
|
||||
import type { ReaderState, TrackingIntent } from "../../state/reader/schema.ts";
|
||||
import type { SetupDraft } from "../../state/reader/onboarding.ts";
|
||||
|
||||
// A translation of the Round 6 B onboarding (design/mockups/round6/b-rail.html, `onboarding()`):
|
||||
// preferences, a two-column game list, one tracking table and view cards.
|
||||
type Kind = "events" | "dailies" | "weeklyMonthly" | "cycles";
|
||||
const KINDS: readonly { id: Kind; label: string; about: string }[] = [
|
||||
{
|
||||
id: "events",
|
||||
label: "Events",
|
||||
about:
|
||||
"Time-limited activities from source pages. Track a deadline once, then complete or ignore it.",
|
||||
},
|
||||
{
|
||||
id: "dailies",
|
||||
label: "Dailies",
|
||||
about: "Chores that reset each game-day, such as daily commissions.",
|
||||
},
|
||||
{
|
||||
id: "weeklyMonthly",
|
||||
label: "Weeklies & monthlies",
|
||||
about:
|
||||
"Goals that reset each week or month, such as weekly bosses or shop restocks.",
|
||||
},
|
||||
{
|
||||
id: "cycles",
|
||||
label: "Cycles",
|
||||
about:
|
||||
"Routines that follow a game’s own schedule. For example, an endgame mode might reset every 14 days. You can follow individual modes later in Routines.",
|
||||
},
|
||||
];
|
||||
const EVENTS_ONLY: TrackingIntent = {
|
||||
events: true,
|
||||
dailies: false,
|
||||
weeklyMonthly: false,
|
||||
cycles: false,
|
||||
};
|
||||
const ORDER = Object.keys(GAMES);
|
||||
const rank = (g: string) =>
|
||||
ORDER.includes(g) ? ORDER.indexOf(g) : ORDER.length;
|
||||
const Check = () => (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
);
|
||||
export function SetupWizard({
|
||||
state,
|
||||
meta = (id) => metaFor(id, {}),
|
||||
onFinish,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
meta?: (id: string) => GameMeta;
|
||||
onFinish: (draft: SetupDraft) => Promise<void>;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft, setDraft] = useState<SetupDraft>({
|
||||
region: "europe",
|
||||
interface: "structured",
|
||||
theme: "dark",
|
||||
scheme: "slate",
|
||||
timezone: null,
|
||||
games: [],
|
||||
tracking: {},
|
||||
showUpcoming: false,
|
||||
view: "soon",
|
||||
});
|
||||
const games = [
|
||||
...new Set(Object.values(state.accounts).map((a) => a.game)),
|
||||
].sort((a, b) => rank(a) - rank(b));
|
||||
const set = (
|
||||
p: Partial<SetupDraft> | ((d: SetupDraft) => Partial<SetupDraft>),
|
||||
) => setDraft((d) => ({ ...d, ...(typeof p === "function" ? p(d) : p) }));
|
||||
const hue = (g: string) => ({ "--g": meta(g).hue }) as CSSProperties;
|
||||
const intentOf = (g: string) => draft.tracking[g] ?? EVENTS_ONLY;
|
||||
const standard = state.prefs.standardPulls === true;
|
||||
const builtins = (g: string, k: Kind) =>
|
||||
k === "events"
|
||||
? 0
|
||||
: k === "dailies"
|
||||
? 1
|
||||
: routinesFor(g).filter(
|
||||
(r) =>
|
||||
offered(r, standard) &&
|
||||
(r.period.unit === "cycle") === (k === "cycles"),
|
||||
).length;
|
||||
const withGames = (choose: (current: string[]) => string[]) =>
|
||||
set((d) => {
|
||||
const chosen = choose(d.games).sort((a, b) => rank(a) - rank(b));
|
||||
return {
|
||||
games: chosen,
|
||||
tracking: Object.fromEntries(
|
||||
chosen.map((g) => [g, d.tracking[g] ?? EVENTS_ONLY]),
|
||||
),
|
||||
};
|
||||
});
|
||||
const next = () => setStep(step + 1);
|
||||
const back = () => setStep(step - 1);
|
||||
const finish = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onFinish(draft);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const foot = (count: string, action: ReactNode, extra?: ReactNode) => (
|
||||
<div className="ob-foot">
|
||||
{step > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
disabled={busy}
|
||||
onClick={back}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<span className="cnt">{count}</span>
|
||||
{extra}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<section className="setup-wizard ob" aria-labelledby="setup-title">
|
||||
<div className="step">Step {step + 1} of 4</div>
|
||||
{step === 0 ? (
|
||||
<>
|
||||
<h1 id="setup-title">Make it yours</h1>
|
||||
<p className="lede">
|
||||
Start with a few preferences. You can change every answer in
|
||||
Settings later.
|
||||
</p>
|
||||
<div className="preference-grid">
|
||||
<label>
|
||||
Default game server
|
||||
<select
|
||||
value={draft.region}
|
||||
onChange={(e) =>
|
||||
set({ region: e.target.value as SetupDraft["region"] })
|
||||
}
|
||||
>
|
||||
<option value="europe">Europe</option>
|
||||
<option value="america">America</option>
|
||||
<option value="asia">Asia</option>
|
||||
</select>
|
||||
<small>
|
||||
Each game starts here. You can choose a different server for a
|
||||
game or track two accounts separately.
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
Appearance
|
||||
<select
|
||||
value={draft.theme}
|
||||
onChange={(e) =>
|
||||
set({ theme: e.target.value as SetupDraft["theme"] })
|
||||
}
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Colour scheme
|
||||
<select
|
||||
value={draft.scheme}
|
||||
onChange={(e) => set({ scheme: e.target.value })}
|
||||
>
|
||||
<option value="slate">Slate</option>
|
||||
<option value="warm">Warm</option>
|
||||
<option value="plum">Plum</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Your timezone
|
||||
<TimezoneSelect
|
||||
value={draft.timezone}
|
||||
onChange={(timezone) => set({ timezone })}
|
||||
/>
|
||||
<small>
|
||||
Automatic follows your device; server reset clocks stay
|
||||
separate.
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
{foot(
|
||||
"Next: choose your games",
|
||||
<button type="button" className="btn solid" onClick={next}>
|
||||
Continue
|
||||
</button>,
|
||||
)}
|
||||
</>
|
||||
) : step === 1 ? (
|
||||
<>
|
||||
<h1 id="setup-title">Which games do you play?</h1>
|
||||
<p className="lede">
|
||||
You'll only see events for these. You can change it, and set each
|
||||
game's server, in Settings.
|
||||
</p>
|
||||
<div className="gpick">
|
||||
{games.map((g) => {
|
||||
const on = draft.games.includes(g);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={g}
|
||||
aria-pressed={on}
|
||||
style={hue(g)}
|
||||
onClick={() =>
|
||||
withGames((current) =>
|
||||
current.includes(g)
|
||||
? current.filter((x) => x !== g)
|
||||
: [...current, g],
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="sw2" aria-hidden="true" />
|
||||
{meta(g).name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{foot(
|
||||
draft.games.length
|
||||
? `${draft.games.length} chosen`
|
||||
: "Pick at least one",
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
disabled={!draft.games.length}
|
||||
onClick={next}
|
||||
>
|
||||
Continue
|
||||
</button>,
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
onClick={() => {
|
||||
withGames(() => [...games]);
|
||||
next();
|
||||
}}
|
||||
>
|
||||
Choose all games instead
|
||||
</button>,
|
||||
)}
|
||||
</>
|
||||
) : step === 2 ? (
|
||||
<TrackingStep
|
||||
draft={draft}
|
||||
set={set}
|
||||
hue={hue}
|
||||
meta={meta}
|
||||
intentOf={intentOf}
|
||||
builtins={builtins}
|
||||
foot={foot(
|
||||
trackingSummary(draft, intentOf, builtins),
|
||||
<button type="button" className="btn solid" onClick={next}>
|
||||
Continue
|
||||
</button>,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1 id="setup-title">Which view should open first?</h1>
|
||||
<p className="lede">
|
||||
Both are always one tab away. This is just what you land on.
|
||||
</p>
|
||||
<ViewCards
|
||||
value={draft.view === "timeline" ? "timeline" : "soon"}
|
||||
onChange={(view) => set({ view })}
|
||||
/>
|
||||
{draft.view === "timeline" && (
|
||||
<div className="look-choice">
|
||||
<h2>Which timeline design?</h2>
|
||||
<p className="lede">
|
||||
Both show the same events. You can switch any time in Settings →
|
||||
Appearance.
|
||||
</p>
|
||||
<div className="viewpick">
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={(draft.timelineLook ?? "new") === "new"}
|
||||
onClick={() => set({ timelineLook: "new" })}
|
||||
>
|
||||
<div className="vprev tl-new" aria-hidden="true">
|
||||
{[
|
||||
["#4EA8DE", "0%", "62%"],
|
||||
["#F2A03D", "18%", "48%"],
|
||||
["#3DD6A0", "8%", "80%"],
|
||||
].map(([g, left, width]) => (
|
||||
<span key={g}>
|
||||
<i className="name" />
|
||||
<i
|
||||
className="bar"
|
||||
style={{ "--g": g, left, width } as CSSProperties}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<b>New look</b>
|
||||
<small>
|
||||
A name column beside each bar, with routines on the same
|
||||
grid.
|
||||
</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={draft.timelineLook === "classic"}
|
||||
onClick={() => set({ timelineLook: "classic" })}
|
||||
>
|
||||
<div className="vprev tl-classic" aria-hidden="true">
|
||||
<em
|
||||
className="lane"
|
||||
style={{ "--g": "#4EA8DE" } as CSSProperties}
|
||||
/>
|
||||
{[
|
||||
["#4EA8DE", "0%", "70%"],
|
||||
["#4EA8DE", "0%", "42%"],
|
||||
["#F2A03D", "24%", "64%"],
|
||||
].map(([g, left, width], i) => (
|
||||
<i
|
||||
key={i}
|
||||
style={{ "--g": g, left, width } as CSSProperties}
|
||||
>
|
||||
<span />
|
||||
</i>
|
||||
))}
|
||||
</div>
|
||||
<b>Classic look</b>
|
||||
<small>
|
||||
Filled bars with the title inside, grouped under each game.
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{foot(
|
||||
"",
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
disabled={busy}
|
||||
onClick={finish}
|
||||
>
|
||||
Start
|
||||
</button>,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function trackingSummary(
|
||||
draft: SetupDraft,
|
||||
intentOf: (g: string) => TrackingIntent,
|
||||
builtins: (g: string, k: Kind) => number,
|
||||
): string {
|
||||
const routineKinds: Kind[] = ["dailies", "weeklyMonthly", "cycles"];
|
||||
const total = draft.games.reduce(
|
||||
(n, g) =>
|
||||
n +
|
||||
routineKinds
|
||||
.filter((k) => intentOf(g)[k])
|
||||
.reduce((t, k) => t + builtins(g, k), 0),
|
||||
0,
|
||||
);
|
||||
if (total)
|
||||
return `Follows ${total} built-in routine${total === 1 ? "" : "s"}. You'll choose where they show once you've seen the views.`;
|
||||
return draft.games.some((g) => routineKinds.some((k) => intentOf(g)[k]))
|
||||
? "Your type choices are saved; no matching built-ins are available yet."
|
||||
: "Events only, for now.";
|
||||
}
|
||||
function TrackingStep({
|
||||
draft,
|
||||
set,
|
||||
hue,
|
||||
meta,
|
||||
intentOf,
|
||||
builtins,
|
||||
foot,
|
||||
}: {
|
||||
draft: SetupDraft;
|
||||
set: (
|
||||
p: Partial<SetupDraft> | ((d: SetupDraft) => Partial<SetupDraft>),
|
||||
) => void;
|
||||
hue: (g: string) => CSSProperties;
|
||||
meta: (id: string) => GameMeta;
|
||||
intentOf: (g: string) => TrackingIntent;
|
||||
builtins: (g: string, k: Kind) => number;
|
||||
foot: ReactNode;
|
||||
}) {
|
||||
const update = (gs: string[], k: Kind, on: boolean) =>
|
||||
set((d) => ({
|
||||
tracking: {
|
||||
...d.tracking,
|
||||
...Object.fromEntries(
|
||||
gs.map((g) => [g, { ...(d.tracking[g] ?? EVENTS_ONLY), [k]: on }]),
|
||||
),
|
||||
},
|
||||
}));
|
||||
const missing = draft.games.flatMap((g) =>
|
||||
KINDS.filter((k) => k.id !== "events" && !builtins(g, k.id)).map(
|
||||
(k) =>
|
||||
`${meta(g).short}: no built-in ${k.label.toLowerCase()} available yet.`,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<h1 id="setup-title">What do you want to track?</h1>
|
||||
<p className="lede">
|
||||
Pick any mix, per game or for all of them. Routines are the things that
|
||||
reset; they get their own tab, where you can follow or drop single ones
|
||||
later.
|
||||
</p>
|
||||
<dl className="type-guide">
|
||||
{KINDS.map((k) => (
|
||||
<div key={k.id}>
|
||||
<dt>{k.label}</dt>
|
||||
<dd>{k.about}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="availability">
|
||||
<p>
|
||||
Every type is selectable. We remember what you want to track even when
|
||||
there is nothing available today.
|
||||
</p>
|
||||
{missing.length > 0 && <p>{missing.join(" ")}</p>}
|
||||
<p>
|
||||
A supported routine between runs is different: its next window will
|
||||
appear when its schedule is available. New built-ins are offered for
|
||||
review in Routines.
|
||||
</p>
|
||||
</div>
|
||||
<div className="matrix-scroll">
|
||||
<table className="mtx">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Game</th>
|
||||
{KINDS.map((k) => (
|
||||
<th scope="col" key={k.id}>
|
||||
{k.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="all">
|
||||
<th scope="row">
|
||||
Same for all<small>Sets every game below</small>
|
||||
</th>
|
||||
{KINDS.map((k) => {
|
||||
const n = draft.games.filter((g) => intentOf(g)[k.id]).length;
|
||||
const all = n === draft.games.length;
|
||||
return (
|
||||
<td key={k.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="tg"
|
||||
aria-pressed={all ? true : n ? "mixed" : false}
|
||||
data-mixed={n > 0 && !all}
|
||||
aria-label={`All games: ${k.label}`}
|
||||
onClick={() => update(draft.games, k.id, !all)}
|
||||
>
|
||||
<Check />
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
{draft.games.map((g) => (
|
||||
<tr key={g} style={hue(g)}>
|
||||
<th scope="row">{meta(g).short}</th>
|
||||
{KINDS.map((k) => (
|
||||
<td key={k.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="tg"
|
||||
aria-pressed={intentOf(g)[k.id]}
|
||||
aria-label={`${meta(g).short}: ${k.label}`}
|
||||
onClick={() => update([g], k.id, !intentOf(g)[k.id])}
|
||||
>
|
||||
<Check />
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="upcoming-choice">
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.showUpcoming}
|
||||
onChange={(e) => set({ showUpcoming: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Show events that haven’t started
|
||||
<small>Include upcoming events in Checklist and Timeline.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{foot}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { manyServers } from "../../state/reader/servers.ts";
|
||||
import { REGION_LABEL } from "../../../shared/time.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { TimezoneSelect, zoneOffset } from "./TimezoneSelect.tsx";
|
||||
import { ResetTimingRow } from "./ResetTimingRow.tsx";
|
||||
|
||||
/** The Round 6 B “Region & time” pane: default server, display timezone, and each server's reset. */
|
||||
export function TimeSettings({
|
||||
state,
|
||||
now,
|
||||
onAction,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const chosen =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const shown = chosen ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const patch = (value: Record<string, unknown>) =>
|
||||
void onAction({ type: "prefs", patch: value, ...actionMeta() });
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!(
|
||||
Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(a.game)
|
||||
),
|
||||
);
|
||||
return (
|
||||
<section className="time-page">
|
||||
<h1>Region & time</h1>
|
||||
<p className="muted-copy">
|
||||
Your default server starts each game's setup. You can change it per game
|
||||
and track more than one server.
|
||||
</p>
|
||||
<section className="settings-group">
|
||||
<h2>Default game server</h2>
|
||||
<div
|
||||
className="segmented"
|
||||
role="group"
|
||||
aria-label="Default game server"
|
||||
>
|
||||
{(["asia", "america", "europe"] as const).map((region) => (
|
||||
<button
|
||||
key={region}
|
||||
aria-pressed={(state.prefs.region ?? "europe") === region}
|
||||
onClick={() => patch({ region })}
|
||||
>
|
||||
{REGION_LABEL[region]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Your timezone</h2>
|
||||
<p className="muted-copy">
|
||||
Automatic follows your device. A chosen timezone changes displayed
|
||||
dates and times; game servers still determine resets and deadlines.
|
||||
</p>
|
||||
<div className="zone-pick">
|
||||
<TimezoneSelect
|
||||
value={chosen}
|
||||
onChange={(timezone) => patch({ timezone })}
|
||||
/>
|
||||
</div>
|
||||
<p className="source-note">
|
||||
Currently displaying {shown} · {zoneOffset(shown)}. Named zones follow
|
||||
daylight saving automatically.
|
||||
</p>
|
||||
</section>
|
||||
{accounts.length > 0 && (
|
||||
<section className="settings-group">
|
||||
<h2>Reset timing for your games</h2>
|
||||
<p className="muted-copy">
|
||||
Each server uses its automatic reset clock. Set an override if the
|
||||
listed time is wrong, and return to Automatic any time. Earlier
|
||||
progress is kept.
|
||||
</p>
|
||||
{accounts.map((a) => (
|
||||
<ResetTimingRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
label={`${meta(a.game).short}${manyServers(state, a.game) ? ` · ${REGION_LABEL[a.region]}` : ""}`}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { manyServers, serverSuffix } from "../../state/reader/servers.ts";
|
||||
import {
|
||||
Fragment,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { occurrencesInFor, validRecords } from "../../state/useCustom.ts";
|
||||
import { CustomEvent } from "../../../shared/custom.ts";
|
||||
import {
|
||||
readerClockFor,
|
||||
readerDayKey,
|
||||
resolveReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import { RoutineControl } from "./DailyPanel.tsx";
|
||||
import { routineRows, type RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts";
|
||||
import { periodOf } from "../../../shared/period.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { canStep, snapDayWidth, stepDayWidth } from "../../state/zoom.ts";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { ReaderEventRow } from "../../state/reader/eventRows.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import {
|
||||
BoardHeader,
|
||||
Timeline as ClassicTimeline,
|
||||
PillChoice,
|
||||
openingLeft,
|
||||
} from "../Timeline.tsx";
|
||||
|
||||
/** `prefs.timelineLook`: the redesign's grid (default) or the paimon.moe-style board. */
|
||||
export const timelineLook = (prefs: Record<string, unknown>) =>
|
||||
prefs.timelineLook === "classic" ? "classic" : "new";
|
||||
/** Dailies stay on the Checklist; the Timeline draws longer routines only. */
|
||||
export const onTimeline = (r: RoutineRow) =>
|
||||
r.placement.followed && r.placement.timeline && r.cadence !== "day";
|
||||
|
||||
/** Periods use each routine's resolved clock, including built-in clock overrides. */
|
||||
export function routinePeriod(row: RoutineRow, now: number) {
|
||||
const key = readerDayKey(now, row.clock),
|
||||
task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id);
|
||||
const period = task
|
||||
? periodOf(task.period, key)
|
||||
: { start: key, days: [key] };
|
||||
const shift = resetShiftMs(row.clock.region, row.game, row.clock);
|
||||
const start = Date.parse(period.start + "T00:00:00Z") - shift;
|
||||
return { start, end: start + period.days.length * DAY };
|
||||
}
|
||||
export function TimelinePage({
|
||||
state,
|
||||
focus = null,
|
||||
focusBar = null,
|
||||
rows,
|
||||
now,
|
||||
onAction,
|
||||
onOpen,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
/** The game in focus, already null when this view hides its focus bar. */
|
||||
focus?: string | null;
|
||||
/** Drawn under the board's header, in either look. */
|
||||
focusBar?: ReactNode;
|
||||
rows: ReaderEventRow[];
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
onOpen: (row: ReaderEventRow) => void;
|
||||
}) {
|
||||
const p = state.prefs,
|
||||
meta = useGameMeta(),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
const [routine, setRoutine] = useState<RoutineRow | null>(null),
|
||||
scroller = useRef<HTMLDivElement>(null),
|
||||
previous = useRef<{ width: number; min: number } | null>(null);
|
||||
const dayWidth = snapDayWidth(
|
||||
typeof p.timelineDayWidth === "number" ? p.timelineDayWidth : 32,
|
||||
);
|
||||
const active = Object.values(state.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!(Array.isArray(p.hiddenGames) && p.hiddenGames.includes(a.game)),
|
||||
);
|
||||
const accounts = active.filter(
|
||||
(a) =>
|
||||
(!focus || a.game === focus) &&
|
||||
(rows.some((r) => r.accountId === a.id) ||
|
||||
routineRows(state, a.id, now).some(onTimeline)),
|
||||
);
|
||||
const min =
|
||||
Math.floor(
|
||||
Math.max(
|
||||
now - 60 * DAY,
|
||||
Math.min(now - 7 * DAY, ...rows.map((r) => r.clock.startsMs)) - 7 * DAY,
|
||||
) / DAY,
|
||||
) * DAY;
|
||||
const max =
|
||||
Math.ceil(
|
||||
Math.max(
|
||||
now + 35 * DAY,
|
||||
...rows.map((r) => r.clock.endsMs ?? now + 35 * DAY),
|
||||
) / DAY,
|
||||
) * DAY;
|
||||
const days = Math.ceil((max - min) / DAY),
|
||||
width = days * dayWidth;
|
||||
const look = timelineLook(p),
|
||||
showEnd = p.timelineLabel === "end",
|
||||
day = (ms: number) => formatInZone(ms, false, zone).replace(/ \d{4}$/, "");
|
||||
const patch = (value: Record<string, unknown>) =>
|
||||
void onAction({ type: "prefs", patch: value, ...actionMeta() });
|
||||
const ownSection = p.resetStrips === true;
|
||||
const options = (
|
||||
<>
|
||||
<PillChoice
|
||||
label="Bar labels"
|
||||
value={showEnd ? "end" : "left"}
|
||||
options={[
|
||||
{ id: "left", label: "Show time left" },
|
||||
{ id: "end", label: "Show end dates" },
|
||||
]}
|
||||
onChange={(timelineLabel) => patch({ timelineLabel })}
|
||||
/>
|
||||
<PillChoice
|
||||
label="Routines"
|
||||
value={ownSection ? "own" : "together"}
|
||||
options={[
|
||||
{ id: "together", label: "Group routines and events" },
|
||||
{ id: "own", label: "Show routines in own section" },
|
||||
]}
|
||||
onChange={(v) => patch({ resetStrips: v === "own" })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const focusRow = focusBar && <div className="timeline-focus">{focusBar}</div>;
|
||||
const liveStarts = rows
|
||||
.filter((r) => r.clock.live)
|
||||
.map((r) => r.clock.startsMs)
|
||||
.sort((a, b) => a - b);
|
||||
const openLeft = (viewport: number) =>
|
||||
openingLeft(
|
||||
((now - min) / DAY) * dayWidth,
|
||||
(((liveStarts[Math.floor(liveStarts.length / 2)] ?? now) - min) / DAY) *
|
||||
dayWidth,
|
||||
viewport,
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
const old = previous.current;
|
||||
const label = el.clientWidth < 700 ? 170 : 250;
|
||||
if (old && old.width !== dayWidth) {
|
||||
const centre = (el.scrollLeft + (el.clientWidth - label) / 2) / old.width;
|
||||
el.scrollLeft = centre * dayWidth - (el.clientWidth - label) / 2;
|
||||
} else if (!old) el.scrollLeft = openLeft(el.clientWidth - label);
|
||||
previous.current = { width: dayWidth, min };
|
||||
}, [dayWidth, min, now]);
|
||||
const expanded = accounts
|
||||
.filter((a) => a.intent.events)
|
||||
.flatMap((a) =>
|
||||
occurrencesInFor(validRecords(state.customEvents, CustomEvent), min, max)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.game === a.game && !state.archive[JSON.stringify([a.id, e.id])],
|
||||
)
|
||||
.map((event) => ({
|
||||
event,
|
||||
accountId: a.id,
|
||||
region: a.region,
|
||||
clockRevision: resolveReaderClock(a, now).revision,
|
||||
clock: readerClockFor(event, a, now),
|
||||
})),
|
||||
);
|
||||
const eventRows = [
|
||||
...new Map(
|
||||
[...rows, ...expanded]
|
||||
.filter(
|
||||
(r) =>
|
||||
(!focus || r.event.game === focus) &&
|
||||
!r.clock.ended &&
|
||||
(p.showUpcoming === true || !r.clock.upcoming),
|
||||
)
|
||||
.map((r) => [JSON.stringify([r.accountId, r.event.id]), r]),
|
||||
).values(),
|
||||
];
|
||||
const order = orderGames(
|
||||
[...new Set(accounts.map((a) => a.game))],
|
||||
Array.isArray(p.gameOrder) ? (p.gameOrder as string[]) : undefined,
|
||||
(g) => meta(g).name,
|
||||
);
|
||||
const ordered = accounts
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
p.timelineGroup === "ending"
|
||||
? Math.min(
|
||||
...eventRows
|
||||
.filter((r) => r.accountId === a.id)
|
||||
.map((r) => r.clock.endsMs ?? Infinity),
|
||||
) -
|
||||
Math.min(
|
||||
...eventRows
|
||||
.filter((r) => r.accountId === b.id)
|
||||
.map((r) => r.clock.endsMs ?? Infinity),
|
||||
)
|
||||
: order.indexOf(a.game) - order.indexOf(b.game),
|
||||
);
|
||||
const pos = (ms: number) => ((ms - min) / DAY) * dayWidth;
|
||||
const span = (start: number, end: number) => ({
|
||||
left: Math.max(0, pos(start)),
|
||||
width: Math.max(4, Math.min(width, pos(end)) - Math.max(0, pos(start))),
|
||||
});
|
||||
const eventRow = (r: ReaderEventRow) => (
|
||||
<div
|
||||
className="timeline-entry"
|
||||
key={JSON.stringify([r.accountId, r.event.id])}
|
||||
style={{ "--g": meta(r.event.game).hue } as CSSProperties}
|
||||
>
|
||||
<button
|
||||
className="timeline-name"
|
||||
onClick={() => onOpen(r)}
|
||||
aria-label={`View ${r.event.title} details`}
|
||||
>
|
||||
{r.event.title}
|
||||
<small>
|
||||
{meta(r.event.game).short}
|
||||
{serverSuffix(state, r.event.game, r.region)}
|
||||
{r.clock.upcoming ? " · Not started yet" : ""}
|
||||
</small>
|
||||
</button>
|
||||
<div className="time-track">
|
||||
<button
|
||||
className={`period-block${r.clock.endsMs === null ? " unknown-end" : ""}${r.clock.upcoming ? " future" : ""}`}
|
||||
style={span(r.clock.startsMs, r.clock.endsMs ?? max)}
|
||||
onClick={() => onOpen(r)}
|
||||
title={r.event.title}
|
||||
>
|
||||
<span>
|
||||
{r.clock.msRemaining === null
|
||||
? "End date unknown"
|
||||
: showEnd && r.clock.endsMs !== null
|
||||
? `Ends ${day(r.clock.endsMs)}`
|
||||
: formatRemaining(r.clock.msRemaining) + " left"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const routineRow = (r: RoutineRow) => {
|
||||
const period = routinePeriod(r, now);
|
||||
return (
|
||||
<div className="routine-timeline-row" key={r.accountId + r.id}>
|
||||
<div
|
||||
className="timeline-entry"
|
||||
style={{ "--g": meta(r.game).hue } as CSSProperties}
|
||||
>
|
||||
<button className="timeline-name" onClick={() => setRoutine(r)}>
|
||||
{r.title}
|
||||
<small>
|
||||
{r.cadence} · {r.status.count}/{r.status.required}
|
||||
</small>
|
||||
</button>
|
||||
<div className="time-track">
|
||||
<button
|
||||
className="period-block routine"
|
||||
style={span(period.start, period.end)}
|
||||
onClick={() => setRoutine(r)}
|
||||
>
|
||||
<i
|
||||
className="fill"
|
||||
style={{
|
||||
width: `${Math.min(100, (r.status.count / r.status.required) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{r.status.count}/{r.status.required} ·{" "}
|
||||
{showEnd
|
||||
? `resets ${formatInZone(period.end, true, zone)}`
|
||||
: `${formatRemaining(period.end - now)} to reset`}
|
||||
</span>
|
||||
</button>
|
||||
<i className="reset-mark" style={{ left: pos(period.end) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const routines = accounts
|
||||
.flatMap((a) => routineRows(state, a.id, now))
|
||||
.filter(onTimeline);
|
||||
const items = [
|
||||
...eventRows.map((row) => ({
|
||||
kind: "event" as const,
|
||||
row,
|
||||
accountId: row.accountId,
|
||||
end: row.clock.endsMs ?? Infinity,
|
||||
})),
|
||||
...routines.map((row) => ({
|
||||
kind: "routine" as const,
|
||||
row,
|
||||
accountId: row.accountId,
|
||||
end: routinePeriod(row, now).end,
|
||||
})),
|
||||
].sort((a, b) => a.end - b.end);
|
||||
const renderItem = (item: (typeof items)[number]) =>
|
||||
item.kind === "event" ? eventRow(item.row) : routineRow(item.row);
|
||||
if (p.timelineGroup === "ending")
|
||||
ordered.sort(
|
||||
(a, b) =>
|
||||
Math.min(
|
||||
...items.filter((i) => i.accountId === a.id).map((i) => i.end),
|
||||
) -
|
||||
Math.min(
|
||||
...items.filter((i) => i.accountId === b.id).map((i) => i.end),
|
||||
),
|
||||
);
|
||||
const queue = p.timelineGroup === "ending" && p.resetStrips !== true;
|
||||
return (
|
||||
<section className="reader-timeline">
|
||||
<div className="rt-top">
|
||||
<h1>Timeline</h1>
|
||||
<p className="sum">
|
||||
One row per event or routine. Your pins decide which routines appear.
|
||||
</p>
|
||||
</div>
|
||||
{look === "new" && (
|
||||
<BoardHeader
|
||||
group={p.timelineGroup === "ending" ? "ending" : "game"}
|
||||
onGroup={(timelineGroup) => patch({ timelineGroup })}
|
||||
controls={options}
|
||||
canOut={canStep(dayWidth, -1)}
|
||||
canIn={canStep(dayWidth, 1)}
|
||||
onZoom={(by) =>
|
||||
patch({ timelineDayWidth: stepDayWidth(dayWidth, by) })
|
||||
}
|
||||
onToday={() => {
|
||||
const el = scroller.current;
|
||||
if (el)
|
||||
el.scrollLeft = openLeft(
|
||||
el.clientWidth - (el.clientWidth < 700 ? 170 : 250),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{look === "new" && focusRow}
|
||||
{look === "classic" ? (
|
||||
<div className="classic-timeline">
|
||||
<ClassicTimeline
|
||||
rows={eventRows}
|
||||
now={now}
|
||||
dayWidth={dayWidth}
|
||||
onZoom={(timelineDayWidth) => patch({ timelineDayWidth })}
|
||||
group={p.timelineGroup === "ending" ? "ending" : "game"}
|
||||
onGroup={(timelineGroup) => patch({ timelineGroup })}
|
||||
showUpcoming={p.showUpcoming === true}
|
||||
splitUpcoming={p.timelineSplitUpcoming !== false}
|
||||
gameOrder={order}
|
||||
onOpen={() => {}}
|
||||
onOpenRow={(row) => onOpen(row as ReaderEventRow)}
|
||||
servers={(game) => manyServers(state, game)}
|
||||
isDone={() => false}
|
||||
controls={options}
|
||||
afterHeader={focusRow}
|
||||
routinesInline={!ownSection}
|
||||
label={(row) =>
|
||||
row.clock.msRemaining === null
|
||||
? null
|
||||
: showEnd && row.clock.endsMs !== null
|
||||
? `Ends ${day(row.clock.endsMs)}`
|
||||
: `${formatRemaining(row.clock.msRemaining)} left`
|
||||
}
|
||||
routines={routines.map((r) => {
|
||||
const period = routinePeriod(r, now);
|
||||
return {
|
||||
key: r.accountId + r.id,
|
||||
game: r.game,
|
||||
title: r.title,
|
||||
detail: `${r.status.count}/${r.status.required}`,
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
done: r.status.met,
|
||||
label: showEnd
|
||||
? `resets ${day(period.end)}`
|
||||
: `${formatRemaining(period.end - now)} to reset`,
|
||||
onOpen: () => setRoutine(r),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
No tracked events or routines to show here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="timeline-scroller" ref={scroller}>
|
||||
<div
|
||||
className="timeline-board"
|
||||
style={
|
||||
{
|
||||
"--track-width": `${width}px`,
|
||||
"--day-width": `${dayWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="time-axis">
|
||||
<strong>Events & routines</strong>
|
||||
<div className="ticks">
|
||||
{Array.from({ length: days }, (_, i) => i)
|
||||
.filter(
|
||||
(i) => i % Math.max(1, Math.ceil(64 / dayWidth)) === 0,
|
||||
)
|
||||
.map((i) => (
|
||||
<span key={i} style={{ left: i * dayWidth }}>
|
||||
{formatInZone(min + i * DAY, false, zone).replace(
|
||||
/ \d{4}$/,
|
||||
"",
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="today-line"
|
||||
style={{ left: `calc(var(--name-width) + ${pos(now)}px)` }}
|
||||
/>
|
||||
{queue
|
||||
? items.map(renderItem)
|
||||
: ordered.map((a) => (
|
||||
<Fragment key={a.id}>
|
||||
<div
|
||||
className="game-separator"
|
||||
style={{ "--g": meta(a.game).hue } as CSSProperties}
|
||||
>
|
||||
<strong>{meta(a.game).name}</strong>
|
||||
<span>
|
||||
{manyServers(state, a.game)
|
||||
? `${a.region} · Events & routines`
|
||||
: "Events & routines"}
|
||||
</span>
|
||||
</div>
|
||||
{p.resetStrips === true ? (
|
||||
<>
|
||||
{eventRows
|
||||
.filter((r) => r.accountId === a.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.clock.endsMs ?? Infinity) -
|
||||
(b.clock.endsMs ?? Infinity),
|
||||
)
|
||||
.map(eventRow)}
|
||||
{routines.some((r) => r.accountId === a.id) && (
|
||||
<section
|
||||
className="reset-strip"
|
||||
aria-label={`${meta(a.game).short} routines`}
|
||||
>
|
||||
<div className="routine-divider">
|
||||
<span>Routines</span>
|
||||
</div>
|
||||
{routines
|
||||
.filter((r) => r.accountId === a.id)
|
||||
.map(routineRow)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
items.filter((i) => i.accountId === a.id).map(renderItem)
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{look === "new" && (
|
||||
<p className="timeline-help">
|
||||
Events and routine periods share one time axis. Dashed bars are
|
||||
routines; the vertical markers show resets. Open a bar to see details
|
||||
or record progress.
|
||||
</p>
|
||||
)}
|
||||
{routine && (
|
||||
<RoutineDetail
|
||||
row={
|
||||
routineRows(state, routine.accountId, now).find(
|
||||
(r) => r.id === routine.id,
|
||||
) ?? routine
|
||||
}
|
||||
server={serverSuffix(state, routine.game, routine.clock.region)}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
onClose={() => setRoutine(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A routine opened from either Timeline look: its period and its progress. */
|
||||
function RoutineDetail({
|
||||
row,
|
||||
server,
|
||||
now,
|
||||
onAction,
|
||||
onClose,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
/** `" · asia"` when the game has several servers, otherwise empty. */
|
||||
server: string;
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null),
|
||||
meta = useGameMeta()(row.game),
|
||||
zone = useContext(DisplayTimeContext),
|
||||
period = routinePeriod(row, now);
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="routine-detail event-detail-sheet"
|
||||
aria-label={row.title}
|
||||
onCancel={onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === dialog.current) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="drawer-head">
|
||||
<p className="detail-game" style={{ color: meta.hue }}>
|
||||
{meta.name}
|
||||
{server}
|
||||
</p>
|
||||
<button onClick={onClose} aria-label="Close routine">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="detail-title">{row.title}</h2>
|
||||
<p className="help">
|
||||
Resets {formatInZone(period.end, true, zone)} · in{" "}
|
||||
{formatRemaining(period.end - now)}
|
||||
</p>
|
||||
<RoutineControl row={row} onAction={onAction} now={now} />
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
/** "GMT+2" for a zone, now; daylight saving included. */
|
||||
export function zoneOffset(zone: string, at = new Date()): string {
|
||||
try {
|
||||
return (
|
||||
new Intl.DateTimeFormat("en", {
|
||||
timeZone: zone,
|
||||
timeZoneName: "shortOffset",
|
||||
})
|
||||
.formatToParts(at)
|
||||
.find((p) => p.type === "timeZoneName")?.value ?? ""
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** "America/Argentina/Buenos_Aires" → "Buenos Aires, Argentina". */
|
||||
export function zoneName(zone: string): string {
|
||||
const parts = zone.split("/").map((p) => p.replace(/_/g, " "));
|
||||
if (parts.length === 1) return parts[0]!;
|
||||
const [, ...rest] = parts;
|
||||
return rest.reverse().join(", ");
|
||||
}
|
||||
|
||||
const REGIONS = [
|
||||
"Europe",
|
||||
"America",
|
||||
"Asia",
|
||||
"Australia",
|
||||
"Pacific",
|
||||
"Africa",
|
||||
"Atlantic",
|
||||
"Indian",
|
||||
"Antarctica",
|
||||
"Arctic",
|
||||
];
|
||||
|
||||
/** Every IANA zone, grouped by region and named by city, A–Z within each group. */
|
||||
export function zoneGroups(zones: readonly string[]) {
|
||||
const groups = new Map<string, { zone: string; name: string }[]>();
|
||||
for (const zone of zones) {
|
||||
const head = zone.split("/")[0]!;
|
||||
const group = REGIONS.includes(head) && zone.includes("/") ? head : "Other";
|
||||
const list = groups.get(group) ?? [];
|
||||
list.push({ zone, name: zoneName(zone) });
|
||||
groups.set(group, list);
|
||||
}
|
||||
return [...REGIONS, "Other"]
|
||||
.filter((g) => groups.has(g))
|
||||
.map((g) => ({
|
||||
group: g,
|
||||
zones: groups.get(g)!.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The display timezone choice: Automatic first, then every zone grouped by
|
||||
* region with its offset. One native select, so the whole list opens at once
|
||||
* and typing a city's first letters jumps to it — no separate search box.
|
||||
*/
|
||||
export function TimezoneSelect({
|
||||
value,
|
||||
onChange,
|
||||
label = "Your timezone",
|
||||
}: {
|
||||
/** null = Automatic (follow the device). */
|
||||
value: string | null;
|
||||
onChange: (zone: string | null) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const device = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const groups = useMemo(
|
||||
() => zoneGroups(Intl.supportedValuesOf("timeZone")),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<select
|
||||
aria-label={label}
|
||||
value={value ?? "automatic"}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value === "automatic" ? null : e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="automatic">
|
||||
Automatic · {device} ({zoneOffset(device)})
|
||||
</option>
|
||||
{value !== null &&
|
||||
!groups.some((g) => g.zones.some((z) => z.zone === value)) && (
|
||||
// A saved zone this browser does not list still shows as chosen.
|
||||
<option value={value}>
|
||||
{value} · {zoneOffset(value)}
|
||||
</option>
|
||||
)}
|
||||
{groups.map(({ group, zones }) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{zones.map(({ zone, name }) => (
|
||||
<option key={zone} value={zone}>
|
||||
{name} · {zoneOffset(zone)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { newCodes } from "../../state/reader/codes.ts";
|
||||
import { trackedGames } from "../../state/reader/updates.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import {
|
||||
MAX_SNOOZE_DAYS,
|
||||
staleSnooze,
|
||||
updateItems,
|
||||
} from "../../state/reader/updates.ts";
|
||||
const SNOOZE_DAYS = [1, 3, MAX_SNOOZE_DAYS];
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { EventFeed } from "../../../shared/feed.ts";
|
||||
import { metaFor } from "../../../shared/games.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
export function UpdatesPanel({
|
||||
state,
|
||||
feed,
|
||||
now,
|
||||
onSources,
|
||||
onAction,
|
||||
codes = null,
|
||||
onCodes,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
feed: EventFeed;
|
||||
now: number;
|
||||
onSources: () => void;
|
||||
onAction: ActionHandler;
|
||||
codes?: CodesFeed | null;
|
||||
onCodes?: (game: string) => void;
|
||||
}) {
|
||||
const items = updateItems(state, feed, now);
|
||||
if (codes)
|
||||
for (const game of trackedGames(state)) {
|
||||
const found = newCodes(codes, state.codeSeen[game] ?? [], game);
|
||||
if (found.length)
|
||||
items.push({ kind: "codes", game, ids: found.map((c) => c.id) });
|
||||
}
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section className="updates-panel" aria-label="Updates">
|
||||
{items.map((item, i) =>
|
||||
item.kind === "sources" ? (
|
||||
<div className="update-row" key={i}>
|
||||
<strong>Event data may be out of date</strong>
|
||||
<span>
|
||||
For games you track:{" "}
|
||||
{item.games.map((g) => metaFor(g, {}).name).join(", ")}
|
||||
</span>
|
||||
<button type="button" onClick={onSources}>
|
||||
Details & reasons
|
||||
</button>
|
||||
<span className="snooze" role="group" aria-label="Hide this notice">
|
||||
Hide for
|
||||
{SNOOZE_DAYS.map((days) => (
|
||||
<button
|
||||
type="button"
|
||||
key={days}
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
staleSnooze: staleSnooze(item.games, now, days),
|
||||
},
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{days} day{days === 1 ? "" : "s"}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
) : item.kind === "events" ? (
|
||||
<div className="update-row" key={i}>
|
||||
<strong>New since you last looked</strong>
|
||||
<span>
|
||||
{item.ids.length} event{item.ids.length === 1 ? "" : "s"}
|
||||
{state.accounts[item.accountId] &&
|
||||
` · ${metaFor(state.accounts[item.accountId]!.game, {}).name}`}
|
||||
{state.accounts[item.accountId] &&
|
||||
serverSuffix(
|
||||
state,
|
||||
state.accounts[item.accountId]!.game,
|
||||
state.accounts[item.accountId]!.region,
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "seen",
|
||||
kind: "eventSeen",
|
||||
id: item.accountId,
|
||||
ids: item.ids,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="update-row" key={i}>
|
||||
<strong>New redeem codes</strong>
|
||||
<span>
|
||||
{item.ids.length} · {metaFor(item.game, {}).name}
|
||||
</span>
|
||||
<button type="button" onClick={() => onCodes?.(item.game)}>
|
||||
View codes
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type MainView = "soon" | "timeline";
|
||||
|
||||
/**
|
||||
* The Round 6 B view cards (`viewCards()`): a drawn Checklist and a drawn
|
||||
* Timeline, used wherever the reader picks the view Event Clock opens on.
|
||||
*/
|
||||
export function ViewCards({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: MainView;
|
||||
onChange: (view: MainView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="viewpick" role="group" aria-label="Default view">
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={value === "soon"}
|
||||
onClick={() => onChange("soon")}
|
||||
>
|
||||
<div className="vprev list" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<b>Checklist</b>
|
||||
<small>A list, closest deadline first.</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={value === "timeline"}
|
||||
onClick={() => onChange("timeline")}
|
||||
>
|
||||
<div className="vprev tl" aria-hidden="true">
|
||||
{(
|
||||
[
|
||||
["#4EA8DE", "4%", "52%", 6],
|
||||
["#F2A03D", "30%", "44%", 24],
|
||||
["#3DD6A0", "50%", "46%", 42],
|
||||
] as const
|
||||
).map(([g, left, width, top]) => (
|
||||
<i
|
||||
key={g}
|
||||
style={{ "--g": g, left, width, top } as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
<em />
|
||||
</div>
|
||||
<b>Timeline</b>
|
||||
<small>Bars per game, today pinned.</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** How long the grand celebration stays mounted: the last wave's sparks included. */
|
||||
export const CELEBRATION_MS = 2600;
|
||||
|
||||
/**
|
||||
* Counts up when a section has just been finished: everything in it done,
|
||||
* more done than a moment ago, and the same number of things as before. That
|
||||
* is the live strip's rule (Dailies.tsx): a list that got shorter (focusing a
|
||||
* game, unpinning) or a page that opens already complete is not the reader
|
||||
* finishing anything, so neither celebrates.
|
||||
*/
|
||||
export function useSectionCelebration(total: number, done: number): number {
|
||||
const [burst, setBurst] = useState(0);
|
||||
const previous = useRef<{ total: number; done: number } | null>(null);
|
||||
const complete = total > 0 && done === total;
|
||||
useEffect(() => {
|
||||
const was = previous.current;
|
||||
previous.current = { total, done };
|
||||
if (was === null || !complete) return;
|
||||
if (was.total === total && done > was.done) setBurst((n) => n + 1);
|
||||
}, [total, done, complete]);
|
||||
useEffect(() => {
|
||||
if (burst === 0) return;
|
||||
const id = setTimeout(() => setBurst(0), CELEBRATION_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [burst]);
|
||||
return burst;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
import type { Theme } from "./theme.ts";
|
||||
import { recordOf } from "./reader/actions.ts";
|
||||
export interface AppearanceTokens {
|
||||
ground: string;
|
||||
surface: string;
|
||||
raised: string;
|
||||
ink: string;
|
||||
muted: string;
|
||||
faint: string;
|
||||
near: string;
|
||||
soon: string;
|
||||
critical: string;
|
||||
hairline: string;
|
||||
}
|
||||
const hex = (v: unknown, fallback: string) =>
|
||||
typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v) ? v : fallback;
|
||||
const rgb = (h: string) => [
|
||||
parseInt(h.slice(1, 3), 16),
|
||||
parseInt(h.slice(3, 5), 16),
|
||||
parseInt(h.slice(5, 7), 16),
|
||||
];
|
||||
const encode = (c: number[]) =>
|
||||
"#" + c.map((x) => Math.round(x).toString(16).padStart(2, "0")).join("");
|
||||
function luminance(h: string) {
|
||||
const c = rgb(h).map((x) => {
|
||||
const v = x / 255;
|
||||
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!;
|
||||
}
|
||||
export function contrastRatio(a: string, b: string) {
|
||||
const x = luminance(a),
|
||||
y = luminance(b);
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||||
}
|
||||
function mix(a: string, b: string, t: number) {
|
||||
const x = rgb(a),
|
||||
y = rgb(b);
|
||||
return encode(x.map((c, i) => c + (y[i]! - c) * t));
|
||||
}
|
||||
export function readableOnSurfaces(
|
||||
hue: string,
|
||||
backgrounds: readonly string[],
|
||||
minimum: number,
|
||||
): string {
|
||||
const original = hex(hue, "#808080"),
|
||||
score = (c: string) =>
|
||||
Math.min(...backgrounds.map((bg) => contrastRatio(c, bg)));
|
||||
if (score(original) >= minimum) return original;
|
||||
let best = original,
|
||||
bestScore = score(best);
|
||||
for (let step = 1; step <= 255; step++)
|
||||
for (const end of ["#ffffff", "#000000"]) {
|
||||
const c = mix(original, end, step / 255),
|
||||
s = score(c);
|
||||
if (s >= minimum) return c;
|
||||
if (s > bestScore) {
|
||||
best = c;
|
||||
bestScore = s;
|
||||
}
|
||||
}
|
||||
for (let n = 0; n < 256; n++) {
|
||||
const c = encode([n, n, n]),
|
||||
s = score(c);
|
||||
if (s >= minimum) return c;
|
||||
if (s > bestScore) {
|
||||
best = c;
|
||||
bestScore = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
export function appearanceTokens(
|
||||
prefs: Record<string, unknown>,
|
||||
theme: Theme,
|
||||
): AppearanceTokens {
|
||||
const light = theme === "light",
|
||||
scheme = prefs.scheme,
|
||||
structured = prefs.interface !== "relaxed";
|
||||
let ground = structured
|
||||
? light
|
||||
? "#eef0f5"
|
||||
: "#0e1117"
|
||||
: light
|
||||
? "#f3f1ec"
|
||||
: "#1a1c24",
|
||||
surface = structured
|
||||
? light
|
||||
? "#ffffff"
|
||||
: "#141923"
|
||||
: light
|
||||
? "#fffdf8"
|
||||
: "#22252f",
|
||||
text = light ? "#28272d" : "#eeeae3",
|
||||
accent = light ? "#315fa5" : "#8eb3f4";
|
||||
if (scheme === "warm") {
|
||||
ground = light ? "#f4eee5" : "#24201c";
|
||||
surface = light ? "#fffaf2" : "#302a24";
|
||||
}
|
||||
if (scheme === "plum") {
|
||||
ground = light ? "#f2edf5" : "#211c29";
|
||||
surface = light ? "#fffaff" : "#2c2536";
|
||||
}
|
||||
if (scheme === "custom") {
|
||||
const custom = recordOf(recordOf(prefs.customPalette)[theme]);
|
||||
ground = hex(custom.ground, ground);
|
||||
surface = hex(custom.surface, surface);
|
||||
text = hex(custom.text, text);
|
||||
accent = hex(custom.accent, accent);
|
||||
}
|
||||
// Preserve saved choices; keep rendered surfaces in one readable luminance family.
|
||||
if (luminance(ground) > 0.4 !== luminance(surface) > 0.4)
|
||||
surface = mix(surface, ground, 0.8);
|
||||
const backgrounds = [ground, surface],
|
||||
ink = readableOnSurfaces(text, backgrounds, 4.5),
|
||||
near = readableOnSurfaces(accent, backgrounds, 4.5);
|
||||
return {
|
||||
ground,
|
||||
surface,
|
||||
ink,
|
||||
near,
|
||||
raised: mix(surface, ink, 0.07),
|
||||
hairline: mix(surface, ink, 0.16),
|
||||
muted: readableOnSurfaces(mix(ink, surface, 0.3), backgrounds, 4.5),
|
||||
faint: readableOnSurfaces(mix(ink, surface, 0.4), backgrounds, 4.5),
|
||||
soon: readableOnSurfaces(light ? "#805a12" : "#e9b95b", backgrounds, 4.5),
|
||||
critical: readableOnSurfaces(
|
||||
light ? "#b12947" : "#ff8598",
|
||||
backgrounds,
|
||||
4.5,
|
||||
),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* The live site's urgency ramp: grey (a week+), blue (under a week), yellow
|
||||
* (under 3 days), red (under a day). Fixed rather than taken from the accent,
|
||||
* so a chosen accent never changes what "under a week" looks like. Each is
|
||||
* only nudged for contrast (3:1, the countdowns are large text) on custom
|
||||
* backgrounds; on the built-in schemes they stay live's values.
|
||||
*/
|
||||
export const URGENCY = {
|
||||
dark: {
|
||||
calm: "#55607f",
|
||||
near: "#5b8def",
|
||||
soon: "#e8b33c",
|
||||
critical: "#ff4d6a",
|
||||
},
|
||||
light: {
|
||||
calm: "#8a92a8",
|
||||
near: "#2b62c6",
|
||||
soon: "#8a6008",
|
||||
critical: "#c81e3c",
|
||||
},
|
||||
} as const;
|
||||
export type UrgencyStep = keyof (typeof URGENCY)["dark"];
|
||||
export const URGENCY_STEPS: readonly UrgencyStep[] = [
|
||||
"calm",
|
||||
"near",
|
||||
"soon",
|
||||
"critical",
|
||||
];
|
||||
|
||||
/**
|
||||
* The reader's own urgency colours for a theme, from `prefs.urgencyPalette`
|
||||
* (`{ dark: { near: "#…" }, light: {…} }`); a step left unset keeps live's.
|
||||
*/
|
||||
export function urgencyChoice(
|
||||
prefs: Record<string, unknown>,
|
||||
theme: "dark" | "light",
|
||||
): Record<UrgencyStep, string> {
|
||||
const own = recordOf(recordOf(prefs.urgencyPalette)[theme]);
|
||||
return Object.fromEntries(
|
||||
URGENCY_STEPS.map((k) => [k, hex(own[k], URGENCY[theme][k])]),
|
||||
) as Record<UrgencyStep, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What each step renders as: the reader's choice or live's, nudged only as far
|
||||
* as 3:1 contrast on the scheme's surfaces (the countdowns are large text).
|
||||
*/
|
||||
export function urgencyTokens(
|
||||
t: AppearanceTokens,
|
||||
prefs: Record<string, unknown> = {},
|
||||
theme: "dark" | "light" = luminance(t.ground) > 0.4 ? "light" : "dark",
|
||||
): Record<UrgencyStep, string> {
|
||||
const chosen = urgencyChoice(prefs, theme),
|
||||
backgrounds = [t.ground, t.surface];
|
||||
return Object.fromEntries(
|
||||
URGENCY_STEPS.map((k) => [
|
||||
k,
|
||||
readableOnSurfaces(chosen[k], backgrounds, 3),
|
||||
]),
|
||||
) as Record<UrgencyStep, string>;
|
||||
}
|
||||
export function applyAppearance(
|
||||
prefs: Record<string, unknown>,
|
||||
theme: Theme,
|
||||
doc: Document = document,
|
||||
) {
|
||||
const t = appearanceTokens(prefs, theme);
|
||||
for (const [k, v] of Object.entries(t))
|
||||
doc.documentElement.style.setProperty(`--color-${k}`, v);
|
||||
doc.documentElement.style.setProperty("--color-ink-strong", t.ink);
|
||||
doc.documentElement.style.setProperty("--color-glow", t.ground);
|
||||
doc.documentElement.style.setProperty("--color-calm", t.muted);
|
||||
for (const [k, v] of Object.entries(
|
||||
urgencyTokens(t, prefs, theme === "light" ? "light" : "dark"),
|
||||
))
|
||||
doc.documentElement.style.setProperty(`--color-urgency-${k}`, v);
|
||||
doc.documentElement.dataset.interface =
|
||||
prefs.interface === "relaxed" ? "relaxed" : "structured";
|
||||
doc.documentElement.dataset.density =
|
||||
prefs.density === "compact" ? "compact" : "standard";
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext } from "react";
|
||||
export const DisplayTimeContext = createContext<string | null>(null);
|
||||
export function formatInZone(
|
||||
instant: number | string,
|
||||
withTime: boolean,
|
||||
zone: string | null,
|
||||
): string {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(withTime
|
||||
? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" as const }
|
||||
: {}),
|
||||
...(zone ? { timeZone: zone } : {}),
|
||||
};
|
||||
try {
|
||||
return new Intl.DateTimeFormat("en-GB", options).format(new Date(instant));
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
...options,
|
||||
timeZone: undefined,
|
||||
}).format(new Date(instant));
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a local wall-clock reading; a gap during daylight saving is not an instant. */
|
||||
export function instantInZone(
|
||||
date: string,
|
||||
time: string,
|
||||
zone: string,
|
||||
): string | null {
|
||||
const wall = `${date}T${time.length === 5 ? time + ":00" : time}`,
|
||||
target = Date.parse(wall + "Z");
|
||||
if (
|
||||
!Number.isFinite(target) ||
|
||||
new Date(target).toISOString().slice(0, 10) !== date
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: zone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const asWall = (ms: number) => {
|
||||
const parts = Object.fromEntries(
|
||||
formatter.formatToParts(new Date(ms)).map((p) => [p.type, p.value]),
|
||||
);
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`;
|
||||
};
|
||||
let candidate = target;
|
||||
for (let i = 0; i < 4; i++)
|
||||
candidate += target - Date.parse(asWall(candidate) + "Z");
|
||||
// Ambiguous fall-back readings choose the earliest matching instant, deterministically.
|
||||
for (let delta = -3 * 60; delta <= 3 * 60; delta++) {
|
||||
const ms = candidate + delta * 60000;
|
||||
if (asWall(ms) === wall) return new Date(ms).toISOString();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LaneId } from "../../../shared/custom.ts";
|
||||
import type { Region } from "../../../shared/schema.ts";
|
||||
import { resetHourFor, serverOffsetUtc } from "../../../shared/time.ts";
|
||||
import { appendClockRevision } from "../../../shared/readerClock.ts";
|
||||
import type { Account, AccountId } from "./schema.ts";
|
||||
export function createAccount(
|
||||
game: LaneId,
|
||||
region: Region,
|
||||
at: string,
|
||||
id: AccountId,
|
||||
): Account {
|
||||
return {
|
||||
id,
|
||||
game,
|
||||
region,
|
||||
intent: {
|
||||
events: true,
|
||||
dailies: false,
|
||||
weeklyMonthly: false,
|
||||
cycles: false,
|
||||
},
|
||||
routines: {},
|
||||
clocks: [
|
||||
{
|
||||
id: "initial",
|
||||
effectiveAt: at,
|
||||
region,
|
||||
reset: null,
|
||||
automaticBase: {
|
||||
offsetUtc: serverOffsetUtc(region, game),
|
||||
hour: resetHourFor(region, game),
|
||||
minute: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export function changeAccountRegion(
|
||||
account: Account,
|
||||
region: Region,
|
||||
at: string,
|
||||
id: string,
|
||||
): Account {
|
||||
return appendClockRevision(account, null, region, at, id);
|
||||
}
|
||||
export function retireAccount(account: Account, at: string): Account {
|
||||
return { ...account, retiredAt: at };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mintLocalId } from "./ids.ts";
|
||||
export function actionMeta() {
|
||||
return { at: new Date().toISOString(), opId: mintLocalId() };
|
||||
}
|
||||
export function recordOf(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
carryForward,
|
||||
carryDaily,
|
||||
carryMarks,
|
||||
carryProgress,
|
||||
RETIRED_IDS,
|
||||
type CarryTable,
|
||||
} from "../retiredIds.ts";
|
||||
import type { ProgressMap } from "../useProgress.ts";
|
||||
import type { DailyLogMap } from "../useDailyLog.ts";
|
||||
import type { Marks } from "../useMarkSet.ts";
|
||||
import { pairKey, type ReaderState } from "./schema.ts";
|
||||
import { recordOf } from "./actions.ts";
|
||||
export function applyReaderAliases(
|
||||
state: ReaderState,
|
||||
table: CarryTable,
|
||||
): ReaderState {
|
||||
const combined = { ...RETIRED_IDS, ...table };
|
||||
if (
|
||||
!Object.values(state.data).some((d) =>
|
||||
[
|
||||
d.progress,
|
||||
d.ignored,
|
||||
...Object.values(d.daily),
|
||||
...Object.values(d.amounts),
|
||||
].some((map) =>
|
||||
Object.keys(combined).some((id) => Object.hasOwn(map, id)),
|
||||
),
|
||||
) &&
|
||||
![...Object.values(state.archive), ...Object.values(state.seenEvents)].some(
|
||||
(r) => Object.hasOwn(combined, r.event.id),
|
||||
)
|
||||
)
|
||||
return state;
|
||||
const data = { ...state.data };
|
||||
for (const [id, d] of Object.entries(data)) {
|
||||
data[id] = {
|
||||
...d,
|
||||
progress: carryProgress(d.progress as ProgressMap, combined),
|
||||
ignored: carryMarks(d.ignored as Marks, combined),
|
||||
amounts: Object.fromEntries(
|
||||
Object.entries(d.amounts).map(([revision, logs]) => [
|
||||
revision,
|
||||
carryForward(
|
||||
logs,
|
||||
(a, b) => {
|
||||
const merged = { ...recordOf(a) };
|
||||
for (const [period, entry] of Object.entries(recordOf(b))) {
|
||||
if (
|
||||
!merged[period] ||
|
||||
String(recordOf(entry).at ?? "") >
|
||||
String(recordOf(merged[period]).at ?? "")
|
||||
)
|
||||
merged[period] = entry;
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
combined,
|
||||
),
|
||||
]),
|
||||
),
|
||||
daily: Object.fromEntries(
|
||||
Object.entries(d.daily).map(([revision, logs]) => [
|
||||
revision,
|
||||
carryDaily(logs as DailyLogMap, combined),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
const archive = { ...state.archive },
|
||||
seenEvents = { ...state.seenEvents };
|
||||
for (const collection of [archive, seenEvents])
|
||||
for (const [key, r] of Object.entries(collection)) {
|
||||
const to = combined[r.event.id];
|
||||
if (!to) continue;
|
||||
const target = pairKey(r.accountId, to);
|
||||
const old = collection[target];
|
||||
const stamp = (v: typeof r) =>
|
||||
"outcomeAt" in v ? String(v.outcomeAt) : String(v.lastSeenAt);
|
||||
if (!old || stamp(r) > stamp(old))
|
||||
collection[target] = { ...r, event: { ...r.event, id: to } };
|
||||
delete collection[key];
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
archive: archive as ReaderState["archive"],
|
||||
seenEvents: seenEvents as ReaderState["seenEvents"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DisplayEvent } from "../../../shared/custom.ts";
|
||||
import {
|
||||
readerClockFor,
|
||||
resolveReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import type { ReaderEventRow } from "./eventRows.ts";
|
||||
import { type ReaderState, type ArchiveRecord, pairKey } from "./schema.ts";
|
||||
import { recordOf } from "./actions.ts";
|
||||
export function captureSeen(
|
||||
state: ReaderState,
|
||||
rows: readonly ReaderEventRow[],
|
||||
at: string,
|
||||
): ReaderState {
|
||||
const seen = { ...state.seenEvents };
|
||||
for (const row of rows) {
|
||||
const key = pairKey(row.accountId, row.event.id),
|
||||
old = seen[key];
|
||||
seen[key] = {
|
||||
accountId: row.accountId,
|
||||
event: row.event,
|
||||
firstSeenAt: old?.firstSeenAt ?? at,
|
||||
lastSeenAt: at,
|
||||
clockRevision: row.clockRevision,
|
||||
};
|
||||
}
|
||||
return { ...state, seenEvents: seen };
|
||||
}
|
||||
export function archiveOutcome(
|
||||
state: ReaderState,
|
||||
accountId: string,
|
||||
eventId: string,
|
||||
outcome: ArchiveRecord["outcome"],
|
||||
at: string,
|
||||
): ReaderState {
|
||||
const key = pairKey(accountId, eventId),
|
||||
seen = state.seenEvents[key],
|
||||
account = state.accounts[accountId],
|
||||
data = state.data[accountId];
|
||||
if (!seen || !account || !data) return state;
|
||||
const clock = resolveReaderClock(account, Date.parse(at)),
|
||||
days = new Set<string>();
|
||||
for (const logs of Object.values(data.daily)) {
|
||||
const raw = recordOf(logs[eventId]);
|
||||
if (Array.isArray(raw.days))
|
||||
for (const day of raw.days) if (typeof day === "string") days.add(day);
|
||||
}
|
||||
const before = recordOf(data.progress[eventId]),
|
||||
ignored = data.ignored[eventId];
|
||||
const progress = { ...data.progress };
|
||||
const ignores = { ...data.ignored };
|
||||
if (outcome === "done") progress[eventId] = { ...before, status: "done", at };
|
||||
if (outcome === "ignored") ignores[eventId] = { at };
|
||||
const record: ArchiveRecord = {
|
||||
accountId,
|
||||
event: seen.event,
|
||||
outcome,
|
||||
outcomeAt: at,
|
||||
resolvedEnd: readerClockFor(seen.event, account, Date.parse(at)).endsMs,
|
||||
clockRevision: clock.revision,
|
||||
progress: before,
|
||||
days: [...days].sort(),
|
||||
amounts: recordOf(data.amounts[clock.revision]?.[eventId]),
|
||||
previousIgnore: ignored ?? null,
|
||||
revisionDays: Object.fromEntries(
|
||||
Object.entries(data.daily).map(([id, logs]) => [
|
||||
id,
|
||||
recordOf(logs[eventId]).days ?? [],
|
||||
]),
|
||||
),
|
||||
revisionAmounts: Object.fromEntries(
|
||||
Object.entries(data.amounts).map(([id, logs]) => [
|
||||
id,
|
||||
logs[eventId] ?? {},
|
||||
]),
|
||||
),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
archive: { ...state.archive, [key]: record },
|
||||
data: {
|
||||
...state.data,
|
||||
[accountId]: { ...data, progress, ignored: ignores },
|
||||
},
|
||||
};
|
||||
}
|
||||
export function restoreArchive(
|
||||
state: ReaderState,
|
||||
accountId: string,
|
||||
eventId: string,
|
||||
): ReaderState {
|
||||
const key = pairKey(accountId, eventId),
|
||||
r = state.archive[key],
|
||||
data = state.data[accountId];
|
||||
if (!r || !data) return state;
|
||||
const archive = { ...state.archive };
|
||||
delete archive[key];
|
||||
const ignored = { ...data.ignored };
|
||||
delete ignored[eventId];
|
||||
const latest = recordOf(data.progress[eventId]);
|
||||
const restored = {
|
||||
...recordOf(r.progress),
|
||||
...(typeof latest.at === "string" && latest.at > r.outcomeAt ? latest : {}),
|
||||
};
|
||||
if (restored.status === "done") delete restored.status;
|
||||
const progress = { ...data.progress, [eventId]: restored };
|
||||
return {
|
||||
...state,
|
||||
archive,
|
||||
data: { ...state.data, [accountId]: { ...data, ignored, progress } },
|
||||
};
|
||||
}
|
||||
export function reconcileArchive(
|
||||
state: ReaderState,
|
||||
_events: readonly DisplayEvent[],
|
||||
now: number,
|
||||
): ReaderState {
|
||||
let next = state;
|
||||
for (const seen of Object.values(state.seenEvents)) {
|
||||
const a = state.accounts[seen.accountId];
|
||||
if (!a || a.retiredAt) continue;
|
||||
const key = pairKey(a.id, seen.event.id);
|
||||
if (next.archive[key]) continue;
|
||||
const clock = readerClockFor(seen.event, a, now);
|
||||
const data = next.data[a.id]!;
|
||||
const p = recordOf(data.progress[seen.event.id]);
|
||||
if (
|
||||
(clock.ended && clock.endsMs !== null) ||
|
||||
p.status === "done" ||
|
||||
data.ignored[seen.event.id]
|
||||
) {
|
||||
const outcome =
|
||||
p.status === "done"
|
||||
? "done"
|
||||
: data.ignored[seen.event.id]
|
||||
? "ignored"
|
||||
: "unfinished";
|
||||
next = archiveOutcome(
|
||||
next,
|
||||
a.id,
|
||||
seen.event.id,
|
||||
outcome,
|
||||
outcome === "unfinished"
|
||||
? new Date(clock.endsMs ?? now).toISOString()
|
||||
: typeof p.at === "string"
|
||||
? p.at
|
||||
: new Date(now).toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ArchiveRecord } from "./schema.ts";
|
||||
import { readGoal } from "../../../shared/goal.ts";
|
||||
import { recordOf } from "./actions.ts";
|
||||
export interface ArchiveStats {
|
||||
monthly: { done: number; eligible: number; rate: number | null };
|
||||
overall: { done: number; eligible: number; rate: number | null };
|
||||
currentRun: number;
|
||||
bestRun: number;
|
||||
checkIns: number;
|
||||
unfinished: number;
|
||||
shortfalls: { id: string; title: string; needed: number }[];
|
||||
games: Record<string, { done: number; ignored: number; unfinished: number }>;
|
||||
}
|
||||
export function archiveStats(
|
||||
records: readonly ArchiveRecord[],
|
||||
now: number,
|
||||
): ArchiveStats {
|
||||
const month = new Date(now).toISOString().slice(0, 7),
|
||||
time = (r: ArchiveRecord) =>
|
||||
r.outcome === "unfinished" && r.resolvedEnd !== null
|
||||
? r.resolvedEnd
|
||||
: Date.parse(r.outcomeAt);
|
||||
const eligible = records
|
||||
.filter(
|
||||
(r) =>
|
||||
r.outcome !== "ignored" &&
|
||||
(r.outcome === "done" ||
|
||||
(r.resolvedEnd !== null && r.resolvedEnd <= now)),
|
||||
)
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
time(a) - time(b) ||
|
||||
a.accountId.localeCompare(b.accountId) ||
|
||||
a.event.id.localeCompare(b.event.id),
|
||||
);
|
||||
const tally = (rows: readonly ArchiveRecord[]) => {
|
||||
const done = rows.filter((r) => r.outcome === "done").length;
|
||||
return {
|
||||
done,
|
||||
eligible: rows.length,
|
||||
rate: rows.length ? Math.round((done / rows.length) * 100) : null,
|
||||
};
|
||||
};
|
||||
let currentRun = 0,
|
||||
bestRun = 0;
|
||||
for (const r of eligible) {
|
||||
currentRun = r.outcome === "done" ? currentRun + 1 : 0;
|
||||
bestRun = Math.max(bestRun, currentRun);
|
||||
}
|
||||
const games: ArchiveStats["games"] = {};
|
||||
for (const r of records) {
|
||||
games[r.event.game] ??= { done: 0, ignored: 0, unfinished: 0 };
|
||||
games[r.event.game]![r.outcome]++;
|
||||
}
|
||||
const shortfalls: ArchiveStats["shortfalls"] = [];
|
||||
for (const r of eligible.filter((r) => r.outcome === "unfinished")) {
|
||||
const goal = readGoal(recordOf(r.progress).goal);
|
||||
if (goal?.period === "event" && goal.measure === "days") {
|
||||
const needed = goal.required - new Set(r.days).size;
|
||||
if (needed > 0)
|
||||
shortfalls.push({ id: r.event.id, title: r.event.title, needed });
|
||||
}
|
||||
}
|
||||
return {
|
||||
monthly: tally(
|
||||
eligible.filter((r) => new Date(time(r)).toISOString().startsWith(month)),
|
||||
),
|
||||
overall: tally(eligible),
|
||||
currentRun,
|
||||
bestRun,
|
||||
checkIns: records.reduce((n, r) => n + new Set(r.days).size, 0),
|
||||
unfinished: records.filter((r) => r.outcome === "unfinished").length,
|
||||
shortfalls,
|
||||
games,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mintLocalId } from "./ids.ts";
|
||||
import { z } from "zod";
|
||||
import { DAY } from "../../../shared/time.ts";
|
||||
import { READER_KEYS, type ReaderState, type SaveResult } from "./schema.ts";
|
||||
import { buildReaderExport } from "./export.ts";
|
||||
import { readLegacy, tryWriteJson } from "./store.ts";
|
||||
const BackupSchema = z.object({
|
||||
id: z.string(),
|
||||
at: z.string().datetime(),
|
||||
payload: z.record(z.unknown()),
|
||||
});
|
||||
export type BackupSnapshot = z.infer<typeof BackupSchema>;
|
||||
export function retainBackups(
|
||||
snapshots: readonly BackupSnapshot[],
|
||||
now: number,
|
||||
): BackupSnapshot[] {
|
||||
const today = Math.floor(now / DAY) * DAY,
|
||||
dailyStart = today - 6 * DAY,
|
||||
weeklyStart = dailyStart - 28 * DAY,
|
||||
month = new Date(weeklyStart);
|
||||
const buckets = new Map<string, BackupSnapshot>();
|
||||
for (const s of snapshots) {
|
||||
const ms = Date.parse(s.at);
|
||||
if (!Number.isFinite(ms) || ms > now) continue;
|
||||
let key: string;
|
||||
if (ms >= dailyStart) key = `day:${Math.floor(ms / DAY)}`;
|
||||
else if (ms >= weeklyStart)
|
||||
key = `week:${Math.floor((dailyStart - ms - 1) / (7 * DAY))}`;
|
||||
else {
|
||||
const d = new Date(ms),
|
||||
index =
|
||||
(month.getUTCFullYear() - d.getUTCFullYear()) * 12 +
|
||||
month.getUTCMonth() -
|
||||
d.getUTCMonth();
|
||||
if (index < 0 || index > 5) continue;
|
||||
key = `month:${index}`;
|
||||
}
|
||||
if (!buckets.has(key) || buckets.get(key)!.at < s.at) buckets.set(key, s);
|
||||
}
|
||||
return [...buckets.values()].sort((a, b) => b.at.localeCompare(a.at));
|
||||
}
|
||||
export function readBackups(storage: Storage): BackupSnapshot[] {
|
||||
try {
|
||||
const p = z
|
||||
.array(BackupSchema)
|
||||
.safeParse(JSON.parse(storage.getItem(READER_KEYS.backups) ?? "[]"));
|
||||
return p.success ? p.data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export function saveBackup(
|
||||
storage: Storage,
|
||||
state: ReaderState,
|
||||
at: string,
|
||||
): SaveResult {
|
||||
const previous = readBackups(storage);
|
||||
const payload = buildReaderExport(state, readLegacy(storage), at);
|
||||
const next = retainBackups(
|
||||
[...previous, { id: mintLocalId(), at, payload }],
|
||||
Date.parse(at),
|
||||
);
|
||||
return tryWriteJson(storage, READER_KEYS.backups, next);
|
||||
}
|
||||
/**
|
||||
* Which part of the retained history a snapshot belongs to, with the same
|
||||
* windows `retainBackups` keeps: the last 7 days, the 4 weeks before them,
|
||||
* then months.
|
||||
*/
|
||||
export function backupTier(
|
||||
at: string,
|
||||
now: number,
|
||||
): "daily" | "weekly" | "monthly" {
|
||||
const today = Math.floor(now / DAY) * DAY,
|
||||
dailyStart = today - 6 * DAY,
|
||||
weeklyStart = dailyStart - 28 * DAY,
|
||||
ms = Date.parse(at);
|
||||
return ms >= dailyStart ? "daily" : ms >= weeklyStart ? "weekly" : "monthly";
|
||||
}
|
||||
|
||||
/** How many Back up now copies are kept; each is a whole export in local storage. */
|
||||
export const MANUAL_BACKUP_LIMIT = 20;
|
||||
/**
|
||||
* The most the Back up now list may take in local storage, in UTF-16 units.
|
||||
* The origin's quota (~5MB) is shared with the reader's own save, which
|
||||
* matters more than any copy of it: past this, Back up now asks the reader
|
||||
* to delete one instead of risking the main save.
|
||||
*/
|
||||
export const MANUAL_BACKUP_BYTES = 1_500_000;
|
||||
/** Backups made with Back up now, newest first. Separate from the automatic history. */
|
||||
export function readManualBackups(storage: Storage): BackupSnapshot[] {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(
|
||||
storage.getItem(READER_KEYS.manualBackups) ?? "[]",
|
||||
);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
// Entry by entry, so one unreadable copy does not discard the others.
|
||||
return raw
|
||||
.flatMap((entry) => {
|
||||
const p = BackupSchema.safeParse(entry);
|
||||
return p.success ? [p.data] : [];
|
||||
})
|
||||
.sort((a, b) => b.at.localeCompare(a.at));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export function saveManualBackup(
|
||||
storage: Storage,
|
||||
state: ReaderState,
|
||||
at: string,
|
||||
): SaveResult | { ok: false; reason: "full" } {
|
||||
const previous = readManualBackups(storage);
|
||||
if (previous.length >= MANUAL_BACKUP_LIMIT)
|
||||
return { ok: false, reason: "full" };
|
||||
const payload = buildReaderExport(state, readLegacy(storage), at);
|
||||
const next = [{ id: mintLocalId(), at, payload }, ...previous];
|
||||
if (JSON.stringify(next).length > MANUAL_BACKUP_BYTES)
|
||||
return { ok: false, reason: "full" };
|
||||
return tryWriteJson(storage, READER_KEYS.manualBackups, next);
|
||||
}
|
||||
export function deleteManualBackup(storage: Storage, id: string): SaveResult {
|
||||
return tryWriteJson(
|
||||
storage,
|
||||
READER_KEYS.manualBackups,
|
||||
readManualBackups(storage).filter((b) => b.id !== id),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export {
|
||||
appendClockRevision,
|
||||
resolveReaderClock,
|
||||
readerDayKey,
|
||||
readerClockFor,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import { appendClockRevision } from "../../../shared/readerClock.ts";
|
||||
import { resetHourFor, serverOffsetUtc } from "../../../shared/time.ts";
|
||||
import type { ReaderState } from "./schema.ts";
|
||||
export function refreshAutomaticClocks(
|
||||
state: ReaderState,
|
||||
at: string,
|
||||
): ReaderState {
|
||||
let next = state;
|
||||
for (const a of Object.values(state.accounts)) {
|
||||
const latest = a.clocks.at(-1)!;
|
||||
if (a.retiredAt || latest.reset !== null) continue;
|
||||
if (
|
||||
latest.automaticBase.offsetUtc === serverOffsetUtc(a.region, a.game) &&
|
||||
latest.automaticBase.hour === resetHourFor(a.region, a.game)
|
||||
)
|
||||
continue;
|
||||
const account = appendClockRevision(
|
||||
a,
|
||||
null,
|
||||
a.region,
|
||||
at,
|
||||
`automatic-${at}`,
|
||||
);
|
||||
next = { ...next, accounts: { ...next.accounts, [a.id]: account } };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CodesFeed, RedeemCode } from "../../../shared/codes.ts";
|
||||
export function newCodes(
|
||||
feed: CodesFeed,
|
||||
seen: readonly string[],
|
||||
game: string,
|
||||
): RedeemCode[] {
|
||||
const known = new Set(seen);
|
||||
return feed.codes.filter((c) => c.game === game && !known.has(c.id));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
asDisplayEvent,
|
||||
asOccurrenceEvent,
|
||||
CustomEvent,
|
||||
type DisplayEvent,
|
||||
} from "../../../shared/custom.ts";
|
||||
import { ruleIdOf, nextOccurrences } from "../../../shared/recurrence.ts";
|
||||
import { endingSoonestFirst } from "../../../shared/time.ts";
|
||||
import {
|
||||
readerClockFor,
|
||||
resolveReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import type { RowEvent } from "../../components/EventRow.tsx";
|
||||
import type { Region } from "../../../shared/schema.ts";
|
||||
import type { ReaderState } from "./schema.ts";
|
||||
export type ReaderEventRow = RowEvent & {
|
||||
accountId: string;
|
||||
region: Region;
|
||||
clockRevision: string;
|
||||
};
|
||||
export function readerEventRows(
|
||||
state: ReaderState,
|
||||
events: readonly DisplayEvent[],
|
||||
now: number,
|
||||
): ReaderEventRow[] {
|
||||
const source = new Map(
|
||||
events.filter((e) => e.status === "published").map((e) => [e.id, e]),
|
||||
);
|
||||
for (const raw of Object.values(state.customEvents)) {
|
||||
const p = CustomEvent.safeParse(raw);
|
||||
if (!p.success) continue;
|
||||
const e = p.data;
|
||||
if (e.repeat === null) source.set(e.id, asDisplayEvent(e));
|
||||
else
|
||||
for (const o of nextOccurrences(e, now, 2))
|
||||
source.set(o.id, asOccurrenceEvent(e, o));
|
||||
}
|
||||
const out: ReaderEventRow[] = [];
|
||||
for (const a of Object.values(state.accounts)) {
|
||||
if (
|
||||
a.retiredAt ||
|
||||
!a.intent.events ||
|
||||
(Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(a.game))
|
||||
)
|
||||
continue;
|
||||
const scoped = new Map(source);
|
||||
for (const seen of Object.values(state.seenEvents))
|
||||
if (
|
||||
seen.accountId === a.id &&
|
||||
!scoped.has(seen.event.id) &&
|
||||
(seen.event.sourceUrl !== null ||
|
||||
state.customEvents[ruleIdOf(seen.event.id)])
|
||||
)
|
||||
scoped.set(seen.event.id, seen.event);
|
||||
for (const event of scoped.values()) {
|
||||
if (event.game !== a.game) continue;
|
||||
const clock = readerClockFor(event, a, now);
|
||||
out.push({
|
||||
event,
|
||||
clock,
|
||||
accountId: a.id,
|
||||
region: a.region,
|
||||
clockRevision: resolveReaderClock(a, now).revision,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.sort(endingSoonestFirst);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ReaderStateSchema, type ReaderState } from "./schema.ts";
|
||||
import { migrateLegacy } from "./migration.ts";
|
||||
export function buildReaderExport(
|
||||
state: ReaderState,
|
||||
legacy: Record<string, string>,
|
||||
at: string,
|
||||
) {
|
||||
return {
|
||||
format: "gacha-tracker-export" as const,
|
||||
version: 2 as const,
|
||||
exportedAt: at,
|
||||
reader: state,
|
||||
legacy: Object.fromEntries(
|
||||
Object.entries(legacy).filter(([k]) => k.startsWith("gacha-tracker:v1:")),
|
||||
),
|
||||
};
|
||||
}
|
||||
export function parseReaderExport(value: unknown): ReaderState | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (v.format !== "gacha-tracker-export") return null;
|
||||
if (v.version === 2) {
|
||||
const parsed = ReaderStateSchema.safeParse(v.reader);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
if (v.version !== undefined && v.version !== 1) return null;
|
||||
if (
|
||||
typeof v.exportedAt !== "string" ||
|
||||
!Number.isFinite(Date.parse(v.exportedAt))
|
||||
)
|
||||
return null;
|
||||
const raw: Record<string, string> = {};
|
||||
for (const key of [
|
||||
"progress",
|
||||
"completions",
|
||||
"daily",
|
||||
"ignored",
|
||||
"prefs",
|
||||
"customGames",
|
||||
"customEvents",
|
||||
"tasks",
|
||||
"amounts",
|
||||
]) {
|
||||
if (v[key] !== undefined) {
|
||||
if (
|
||||
typeof v[key] !== "object" ||
|
||||
v[key] === null ||
|
||||
Array.isArray(v[key])
|
||||
)
|
||||
return null;
|
||||
raw[`gacha-tracker:v1:${key}`] = JSON.stringify(v[key]);
|
||||
}
|
||||
}
|
||||
let n = 0;
|
||||
return migrateLegacy(raw, [], v.exportedAt, () => `account:import${++n}`);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AccountId } from "./schema.ts";
|
||||
|
||||
/** Browser-local identities, including HTTP LAN/WireGuard hosts. getRandomValues
|
||||
* works there; randomUUID is restricted to secure contexts such as HTTPS/localhost.
|
||||
* Existing identities remain untouched. These tokens are not authentication keys.
|
||||
*/
|
||||
export function mintLocalId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
export function mintAccountId(): AccountId {
|
||||
return `account:${mintLocalId()}`;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { ReaderState } from "./schema.ts";
|
||||
import { pairKey } from "./schema.ts";
|
||||
import { recordOf } from "./actions.ts";
|
||||
const stamp = (v: unknown) => {
|
||||
const r = recordOf(v);
|
||||
return typeof r.updatedAt === "string"
|
||||
? r.updatedAt
|
||||
: typeof r.at === "string"
|
||||
? r.at
|
||||
: "";
|
||||
};
|
||||
function mergeRecords(
|
||||
a: Record<string, unknown>,
|
||||
b: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...a };
|
||||
for (const [id, value] of Object.entries(b)) {
|
||||
if (next[id] === undefined) next[id] = value;
|
||||
else if (stamp(value) > stamp(next[id]))
|
||||
next[id] = { ...recordOf(next[id]), ...recordOf(value) };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
export function mergeReaderData(
|
||||
current: ReaderState,
|
||||
incoming: ReaderState,
|
||||
): ReaderState {
|
||||
const next = {
|
||||
...incoming,
|
||||
...current,
|
||||
importedExtensions: [
|
||||
...(Array.isArray(current.importedExtensions)
|
||||
? current.importedExtensions
|
||||
: []),
|
||||
...(Array.isArray(incoming.importedExtensions)
|
||||
? incoming.importedExtensions
|
||||
: []),
|
||||
{
|
||||
migratedAt: incoming.migratedAt,
|
||||
root: Object.fromEntries(
|
||||
Object.entries(incoming).filter(
|
||||
([k]) =>
|
||||
![
|
||||
"version",
|
||||
"revision",
|
||||
"migratedAt",
|
||||
"legacyDigest",
|
||||
"accounts",
|
||||
"data",
|
||||
"archive",
|
||||
"seenEvents",
|
||||
"prefs",
|
||||
"customGames",
|
||||
"customEvents",
|
||||
"tasks",
|
||||
"codeSeen",
|
||||
"codeRedeemed",
|
||||
"eventSeen",
|
||||
"operations",
|
||||
"fieldTimes",
|
||||
"legacyRaw",
|
||||
"legacyAccounts",
|
||||
"importedExtensions",
|
||||
].includes(k),
|
||||
),
|
||||
),
|
||||
accounts: Object.fromEntries(
|
||||
Object.entries(incoming.accounts).map(([id, a]) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
Object.entries(a).filter(
|
||||
([k]) =>
|
||||
![
|
||||
"id",
|
||||
"game",
|
||||
"region",
|
||||
"intent",
|
||||
"routines",
|
||||
"clocks",
|
||||
"retiredAt",
|
||||
].includes(k),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
codeSeen: Object.fromEntries(
|
||||
[
|
||||
...new Set([
|
||||
...Object.keys(current.codeSeen),
|
||||
...Object.keys(incoming.codeSeen),
|
||||
]),
|
||||
].map((k) => [
|
||||
k,
|
||||
[
|
||||
...new Set([
|
||||
...(current.codeSeen[k] ?? []),
|
||||
...(incoming.codeSeen[k] ?? []),
|
||||
]),
|
||||
],
|
||||
]),
|
||||
),
|
||||
eventSeen: { ...current.eventSeen },
|
||||
revision: current.revision + 1,
|
||||
accounts: { ...current.accounts },
|
||||
data: { ...current.data },
|
||||
archive: { ...current.archive },
|
||||
seenEvents: { ...current.seenEvents },
|
||||
customGames: mergeRecords(current.customGames, incoming.customGames),
|
||||
customEvents: mergeRecords(current.customEvents, incoming.customEvents),
|
||||
tasks: mergeRecords(current.tasks, incoming.tasks),
|
||||
codeRedeemed: { ...current.codeRedeemed },
|
||||
};
|
||||
next.importedExtensions = [
|
||||
...new Map(
|
||||
next.importedExtensions.map((value) => [JSON.stringify(value), value]),
|
||||
).values(),
|
||||
];
|
||||
for (const account of Object.values(incoming.accounts)) {
|
||||
const mine = Object.values(next.accounts).find(
|
||||
(a) =>
|
||||
a.game === account.game && a.region === account.region && !a.retiredAt,
|
||||
);
|
||||
let id = mine?.id ?? account.id;
|
||||
let suffix = 0;
|
||||
while (!mine && next.accounts[id]) id = `account:import-${++suffix}`;
|
||||
next.accounts[id] = mine ?? { ...account, id };
|
||||
next.eventSeen[id] = [
|
||||
...new Set([
|
||||
...(next.eventSeen[id] ?? []),
|
||||
...(incoming.eventSeen[account.id] ?? []),
|
||||
]),
|
||||
];
|
||||
const old = next.data[id],
|
||||
data = incoming.data[account.id];
|
||||
if (!data) continue;
|
||||
|
||||
const revisionMap = new Map<string, string>();
|
||||
for (const clock of account.clocks) {
|
||||
const clocks = next.accounts[id]!.clocks;
|
||||
const existing = clocks.find(
|
||||
(c) =>
|
||||
JSON.stringify({ ...c, id: null, historicalOnly: null }) ===
|
||||
JSON.stringify({ ...clock, id: null, historicalOnly: null }),
|
||||
);
|
||||
if (existing) revisionMap.set(clock.id, existing.id);
|
||||
else {
|
||||
let clockId = clock.id;
|
||||
while (clocks.some((c) => c.id === clockId)) clockId += "-import";
|
||||
next.accounts[id] = {
|
||||
...next.accounts[id]!,
|
||||
clocks: [
|
||||
{
|
||||
...clock,
|
||||
id: clockId,
|
||||
...(mine ? { historicalOnly: true } : {}),
|
||||
},
|
||||
...next.accounts[id]!.clocks,
|
||||
],
|
||||
};
|
||||
revisionMap.set(clock.id, clockId);
|
||||
}
|
||||
}
|
||||
const daily = { ...old?.daily },
|
||||
amounts = { ...old?.amounts };
|
||||
for (const [revision, items] of Object.entries(data.daily)) {
|
||||
const target = revisionMap.get(revision) ?? revision;
|
||||
const merged = { ...daily[target] };
|
||||
for (const [item, entry] of Object.entries(items)) {
|
||||
const a = recordOf(merged[item]),
|
||||
b = recordOf(entry);
|
||||
const days = [
|
||||
...new Set([
|
||||
...(Array.isArray(a.days) ? a.days : []),
|
||||
...(Array.isArray(b.days) ? b.days : []),
|
||||
]),
|
||||
]
|
||||
.filter((d): d is string => typeof d === "string")
|
||||
.sort();
|
||||
merged[item] = {
|
||||
...a,
|
||||
...b,
|
||||
days,
|
||||
at: stamp(a) > stamp(b) ? stamp(a) : stamp(b),
|
||||
};
|
||||
}
|
||||
daily[target] = merged;
|
||||
}
|
||||
for (const [revision, items] of Object.entries(data.amounts)) {
|
||||
const target = revisionMap.get(revision) ?? revision;
|
||||
const merged = { ...amounts[target] };
|
||||
for (const [item, entry] of Object.entries(items))
|
||||
merged[item] = mergeRecords(recordOf(merged[item]), recordOf(entry));
|
||||
amounts[target] = merged;
|
||||
}
|
||||
next.data[id] = {
|
||||
...old,
|
||||
...data,
|
||||
progress: mergeRecords(old?.progress ?? {}, data.progress),
|
||||
ignored: mergeRecords(old?.ignored ?? {}, data.ignored),
|
||||
daily,
|
||||
amounts,
|
||||
};
|
||||
for (const r of Object.values(incoming.archive).filter(
|
||||
(r) => r.accountId === account.id,
|
||||
)) {
|
||||
const key = pairKey(id, r.event.id);
|
||||
if (!next.archive[key] || next.archive[key]!.outcomeAt < r.outcomeAt)
|
||||
next.archive[key] = {
|
||||
...r,
|
||||
accountId: id,
|
||||
clockRevision: revisionMap.get(r.clockRevision) ?? r.clockRevision,
|
||||
};
|
||||
}
|
||||
for (const r of Object.values(incoming.seenEvents).filter(
|
||||
(r) => r.accountId === account.id,
|
||||
)) {
|
||||
const key = pairKey(id, r.event.id);
|
||||
if (
|
||||
!next.seenEvents[key] ||
|
||||
next.seenEvents[key]!.lastSeenAt < r.lastSeenAt
|
||||
)
|
||||
next.seenEvents[key] = {
|
||||
...r,
|
||||
accountId: id,
|
||||
clockRevision: revisionMap.get(r.clockRevision) ?? r.clockRevision,
|
||||
};
|
||||
}
|
||||
next.codeRedeemed[id] = {
|
||||
...current.codeRedeemed[id],
|
||||
...incoming.codeRedeemed[account.id],
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ReaderState } from "./schema.ts";
|
||||
import { legacyRecord, legacyDigest, migrateLegacy } from "./migration.ts";
|
||||
import { KEYS } from "../storage.ts";
|
||||
import { recordOf } from "./actions.ts";
|
||||
import { reduceReader } from "./reducer.ts";
|
||||
export function reconcileLegacy(
|
||||
state: ReaderState,
|
||||
raw: Record<string, string>,
|
||||
at: string,
|
||||
): ReaderState {
|
||||
const digest = legacyDigest(raw);
|
||||
if (digest === state.legacyDigest) return state;
|
||||
const baseline = recordOf(state.legacyRaw) as Record<string, string>;
|
||||
let n = 0;
|
||||
const incoming = migrateLegacy(
|
||||
raw,
|
||||
Object.keys(recordOf(state.legacyAccounts)),
|
||||
at,
|
||||
() => `account:legacy${++n}`,
|
||||
);
|
||||
const previous = migrateLegacy(
|
||||
baseline,
|
||||
Object.keys(recordOf(state.legacyAccounts)),
|
||||
at,
|
||||
() => `account:before${++n}`,
|
||||
);
|
||||
let next = state;
|
||||
for (const a of Object.values(incoming.accounts)) {
|
||||
const originalId = recordOf(state.legacyAccounts)[a.game];
|
||||
const account =
|
||||
typeof originalId === "string"
|
||||
? next.accounts[originalId]
|
||||
: Object.values(next.accounts).find((x) => x.game === a.game);
|
||||
if (!account) continue;
|
||||
const beforeAccount = Object.values(previous.accounts).find(
|
||||
(b) => b.game === a.game,
|
||||
);
|
||||
const beforeData = beforeAccount
|
||||
? previous.data[beforeAccount.id]
|
||||
: undefined;
|
||||
const includeRemoved = (
|
||||
now: Record<string, unknown>,
|
||||
old: Record<string, unknown>,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
[...new Set([...Object.keys(now), ...Object.keys(old)])].map((id) => [
|
||||
id,
|
||||
now[id] ?? { at: recordOf(old[id]).at ?? state.migratedAt },
|
||||
]),
|
||||
);
|
||||
const data = incoming.data[a.id]!,
|
||||
revision = account.clocks[0]!.id;
|
||||
for (const [item, value] of Object.entries(
|
||||
includeRemoved(data.progress, beforeData?.progress ?? {}),
|
||||
)) {
|
||||
if (
|
||||
Object.hasOwn(data.progress, item) &&
|
||||
JSON.stringify(value) ===
|
||||
JSON.stringify(legacyRecord(baseline, KEYS.progress)[item])
|
||||
)
|
||||
continue;
|
||||
const record = recordOf(value),
|
||||
timestamp = typeof record.at === "string" ? record.at : at,
|
||||
patch = { ...record };
|
||||
delete patch.at;
|
||||
for (const field of ["status", "effort", "note", "daily", "goal"])
|
||||
if (
|
||||
!(field in patch) &&
|
||||
field in recordOf(next.data[account.id]?.progress[item])
|
||||
)
|
||||
patch[field] = null;
|
||||
try {
|
||||
next = reduceReader(next, {
|
||||
type: "progress",
|
||||
accountId: account.id,
|
||||
itemId: item,
|
||||
patch,
|
||||
at: timestamp,
|
||||
opId: `legacy:${digest}:${account.id}:${item}`,
|
||||
});
|
||||
} catch {
|
||||
/* A newer v2 field/tombstone wins; raw legacy bytes remain preserved. */
|
||||
}
|
||||
}
|
||||
for (const [item, value] of Object.entries(
|
||||
includeRemoved(data.daily.initial ?? {}, beforeData?.daily.initial ?? {}),
|
||||
)) {
|
||||
if (
|
||||
JSON.stringify(value) ===
|
||||
JSON.stringify(legacyRecord(baseline, KEYS.daily)[item])
|
||||
)
|
||||
continue;
|
||||
const record = recordOf(value),
|
||||
old = recordOf(legacyRecord(baseline, KEYS.daily)[item]);
|
||||
const days = new Set(
|
||||
Array.isArray(record.days)
|
||||
? record.days.filter((d): d is string => typeof d === "string")
|
||||
: [],
|
||||
);
|
||||
const before = new Set(
|
||||
Array.isArray(old.days)
|
||||
? old.days.filter((d): d is string => typeof d === "string")
|
||||
: [],
|
||||
);
|
||||
for (const day of new Set([...days, ...before]))
|
||||
if (days.has(day) !== before.has(day))
|
||||
try {
|
||||
next = reduceReader(next, {
|
||||
type: "day",
|
||||
accountId: account.id,
|
||||
revisionId: revision,
|
||||
itemId: item,
|
||||
day,
|
||||
checked: days.has(day),
|
||||
at: typeof record.at === "string" ? record.at : at,
|
||||
opId: `legacy:${digest}:${item}:${day}`,
|
||||
});
|
||||
} catch {
|
||||
/* Do not resurrect a newer untick. */
|
||||
}
|
||||
}
|
||||
for (const [item, value] of Object.entries(
|
||||
includeRemoved(
|
||||
data.amounts.initial ?? {},
|
||||
beforeData?.amounts.initial ?? {},
|
||||
),
|
||||
))
|
||||
for (const [period, entry] of Object.entries(
|
||||
includeRemoved(
|
||||
recordOf(value),
|
||||
recordOf(beforeData?.amounts.initial?.[item]),
|
||||
),
|
||||
)) {
|
||||
if (
|
||||
JSON.stringify(entry) ===
|
||||
JSON.stringify(
|
||||
recordOf(legacyRecord(baseline, KEYS.amounts)[item])[period],
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const e = recordOf(entry);
|
||||
if (
|
||||
e.value !== undefined &&
|
||||
(typeof e.value !== "number" || !Number.isFinite(e.value))
|
||||
)
|
||||
continue;
|
||||
try {
|
||||
next = reduceReader(next, {
|
||||
type: "amount",
|
||||
accountId: account.id,
|
||||
revisionId: revision,
|
||||
itemId: item,
|
||||
period,
|
||||
value: typeof e.value === "number" ? e.value : null,
|
||||
at: typeof e.at === "string" ? e.at : at,
|
||||
opId: `legacy:${digest}:${item}:${period}`,
|
||||
});
|
||||
} catch {
|
||||
/* Preserve newer amounts. */
|
||||
}
|
||||
}
|
||||
for (const [item, value] of Object.entries(
|
||||
includeRemoved(data.ignored, beforeData?.ignored ?? {}),
|
||||
)) {
|
||||
if (
|
||||
Object.hasOwn(data.ignored, item) &&
|
||||
JSON.stringify(value) ===
|
||||
JSON.stringify(legacyRecord(baseline, KEYS.ignored)[item])
|
||||
)
|
||||
continue;
|
||||
const e = recordOf(value);
|
||||
try {
|
||||
next = reduceReader(next, {
|
||||
type: "ignore",
|
||||
accountId: account.id,
|
||||
itemId: item,
|
||||
ignored: Object.hasOwn(data.ignored, item),
|
||||
at: typeof e.at === "string" ? e.at : at,
|
||||
opId: `legacy:${digest}:ignore:${item}`,
|
||||
});
|
||||
} catch {
|
||||
/* Preserve newer restore decisions. */
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [collection, key] of [
|
||||
["tasks", KEYS.tasks],
|
||||
["customGames", KEYS.customGames],
|
||||
["customEvents", KEYS.customEvents],
|
||||
] as const)
|
||||
for (const id of new Set([
|
||||
...Object.keys(legacyRecord(raw, key)),
|
||||
...Object.keys(legacyRecord(baseline, key)),
|
||||
])) {
|
||||
const value = legacyRecord(raw, key)[id] ?? null;
|
||||
if (
|
||||
JSON.stringify(value) ===
|
||||
JSON.stringify(legacyRecord(baseline, key)[id])
|
||||
)
|
||||
continue;
|
||||
const e = recordOf(value ?? legacyRecord(baseline, key)[id]);
|
||||
try {
|
||||
next = reduceReader(next, {
|
||||
type: "record",
|
||||
collection,
|
||||
id,
|
||||
value,
|
||||
at:
|
||||
typeof e.updatedAt === "string"
|
||||
? e.updatedAt
|
||||
: typeof e.at === "string"
|
||||
? e.at
|
||||
: at,
|
||||
opId: `legacy:${digest}:${collection}:${id}`,
|
||||
});
|
||||
} catch {
|
||||
/* Preserve newer edits. */
|
||||
}
|
||||
}
|
||||
return { ...next, legacyDigest: digest, legacyRaw: raw };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Serialize the envelope across tabs, including browsers without Web Locks. */
|
||||
export async function withReaderLock<T>(run: () => T): Promise<T> {
|
||||
if (typeof navigator !== "undefined" && navigator.locks)
|
||||
return navigator.locks.request("gacha-tracker:v2:reader", run);
|
||||
// Node tests execute synchronous localStorage writes in one process. Browsers
|
||||
// instead use a readwrite IndexedDB transaction as the cross-context mutex.
|
||||
if (typeof window === "undefined") return run();
|
||||
if (typeof indexedDB === "undefined") throw new Error("unavailable");
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const request = indexedDB.open("gacha-tracker-reader-lock", 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("mutex");
|
||||
request.onerror = () => reject(new Error("unavailable"));
|
||||
request.onblocked = () => reject(new Error("unavailable"));
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction("mutex", "readwrite");
|
||||
let value: T;
|
||||
const gate = transaction.objectStore("mutex").get("reader");
|
||||
gate.onsuccess = () => {
|
||||
try {
|
||||
value = run();
|
||||
} catch (e) {
|
||||
transaction.abort();
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
transaction.oncomplete = () => {
|
||||
db.close();
|
||||
resolve(value);
|
||||
};
|
||||
transaction.onabort = transaction.onerror = () => {
|
||||
db.close();
|
||||
reject(new Error("unavailable"));
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { ruleIdOf } from "../../../shared/recurrence.ts";
|
||||
import type { LaneId } from "../../../shared/custom.ts";
|
||||
import { GameId } from "../../../shared/schema.ts";
|
||||
import { resetHourFor, serverOffsetUtc } from "../../../shared/time.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { shownFor } from "../../../shared/perGame.ts";
|
||||
import {
|
||||
adoptNewLanes,
|
||||
choreRule,
|
||||
routineRule,
|
||||
restorePrefsValue,
|
||||
} from "../usePrefs.ts";
|
||||
import { KEYS } from "../storage.ts";
|
||||
import {
|
||||
type AccountId,
|
||||
type ReaderState,
|
||||
emptyAccountData,
|
||||
} from "./schema.ts";
|
||||
export function legacyRecord(
|
||||
raw: Record<string, string>,
|
||||
key: string,
|
||||
): Record<string, unknown> {
|
||||
try {
|
||||
const v: unknown = JSON.parse(raw[key] ?? "{}");
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
export function legacyDigest(raw: Record<string, string>): string {
|
||||
// Stable comparison stamp; not a security or identity hash.
|
||||
let n = 2166136261;
|
||||
for (const c of JSON.stringify(
|
||||
Object.keys(raw)
|
||||
.sort()
|
||||
.map((k) => [k, raw[k]]),
|
||||
))
|
||||
n = Math.imul(n ^ c.charCodeAt(0), 16777619);
|
||||
return (n >>> 0).toString(16);
|
||||
}
|
||||
export function migrateLegacy(
|
||||
raw: Record<string, string>,
|
||||
games: readonly LaneId[],
|
||||
at: string,
|
||||
mint: () => AccountId,
|
||||
): ReaderState {
|
||||
const originalPrefs = restorePrefsValue(legacyRecord(raw, KEYS.prefs));
|
||||
const prefs = {
|
||||
...originalPrefs,
|
||||
...adoptNewLanes(
|
||||
games,
|
||||
originalPrefs.knownGames,
|
||||
originalPrefs.hiddenGames,
|
||||
),
|
||||
};
|
||||
const tasks = legacyRecord(raw, KEYS.tasks),
|
||||
ownGames = legacyRecord(raw, KEYS.customGames),
|
||||
ownEvents = legacyRecord(raw, KEYS.customEvents);
|
||||
const progress = legacyRecord(raw, KEYS.progress),
|
||||
legacy = legacyRecord(raw, KEYS.completions);
|
||||
for (const [id, value] of Object.entries(legacy))
|
||||
if (
|
||||
progress[id] === undefined &&
|
||||
typeof value === "object" &&
|
||||
value !== null
|
||||
)
|
||||
progress[id] = { ...value, status: "done" };
|
||||
const daily = legacyRecord(raw, KEYS.daily),
|
||||
ignored = legacyRecord(raw, KEYS.ignored),
|
||||
amounts = legacyRecord(raw, KEYS.amounts);
|
||||
const lanes = new Set([...games, ...Object.keys(ownGames)]);
|
||||
const gameOf = (id: string): string | undefined => {
|
||||
const task = tasks[id] as { game?: unknown } | undefined;
|
||||
const event = ownEvents[ruleIdOf(id)] as { game?: unknown } | undefined;
|
||||
if (typeof task?.game === "string") return task.game;
|
||||
if (typeof event?.game === "string") return event.game;
|
||||
const parts = id.split(":");
|
||||
const candidate =
|
||||
parts[0] === "routine" || parts[0] === "dailies" ? parts[1] : parts[0];
|
||||
return GameId.safeParse(candidate).success ? candidate : undefined;
|
||||
};
|
||||
for (const map of [progress, daily, ignored, amounts, tasks, ownEvents])
|
||||
for (const id of Object.keys(map)) {
|
||||
const game = gameOf(id);
|
||||
if (game) lanes.add(game);
|
||||
}
|
||||
const state: ReaderState = {
|
||||
version: 2,
|
||||
revision: 0,
|
||||
migratedAt: at,
|
||||
legacyDigest: legacyDigest(raw),
|
||||
accounts: {},
|
||||
data: {},
|
||||
archive: {},
|
||||
seenEvents: {},
|
||||
prefs: { ...prefs },
|
||||
customGames: ownGames,
|
||||
customEvents: ownEvents,
|
||||
tasks,
|
||||
codeSeen: {},
|
||||
codeRedeemed: {},
|
||||
eventSeen: {},
|
||||
legacyRaw: raw,
|
||||
legacyAccounts: {},
|
||||
};
|
||||
for (const game of lanes) {
|
||||
const id = mint();
|
||||
if (state.accounts[id])
|
||||
throw new Error("Duplicate minted account identity");
|
||||
const revision = "initial";
|
||||
const routineRuleValue = routineRule(prefs);
|
||||
const chore = shownFor(choreRule(prefs), game);
|
||||
const routines = Object.fromEntries(
|
||||
routinesFor(game).map((r) => {
|
||||
const followed =
|
||||
shownFor(routineRuleValue, game) &&
|
||||
!(prefs.routinesHidden ?? []).includes(r.id) &&
|
||||
!(r.pulls === "standard" && !prefs.standardPulls);
|
||||
return [r.id, { followed, checklist: followed, timeline: followed }];
|
||||
}),
|
||||
);
|
||||
for (const [taskId, task] of Object.entries(tasks))
|
||||
if ((task as { game?: unknown })?.game === game) {
|
||||
const unit = (task as { period?: { unit?: string } }).period?.unit;
|
||||
const shown =
|
||||
unit === "week"
|
||||
? prefs.showWeeklyTasks
|
||||
: unit === "month"
|
||||
? prefs.showMonthlyTasks
|
||||
: prefs.showCycleTasks;
|
||||
routines[taskId] = {
|
||||
followed: true,
|
||||
checklist: shown,
|
||||
timeline: shown,
|
||||
};
|
||||
}
|
||||
if (GameId.safeParse(game).success)
|
||||
routines[`dailies:${game}`] = {
|
||||
followed: chore,
|
||||
checklist: chore,
|
||||
timeline: chore,
|
||||
};
|
||||
state.accounts[id] = {
|
||||
id,
|
||||
game,
|
||||
region: prefs.region,
|
||||
intent: {
|
||||
events: !prefs.hiddenGames.includes(game),
|
||||
dailies: chore,
|
||||
weeklyMonthly: shownFor(routineRuleValue, game),
|
||||
cycles: shownFor(routineRuleValue, game),
|
||||
},
|
||||
routines,
|
||||
clocks: [
|
||||
{
|
||||
id: revision,
|
||||
effectiveAt: "1970-01-01T00:00:00.000Z",
|
||||
region: prefs.region,
|
||||
reset: null,
|
||||
automaticBase: {
|
||||
offsetUtc: serverOffsetUtc(prefs.region, game),
|
||||
hour: resetHourFor(prefs.region, game),
|
||||
minute: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const data = emptyAccountData();
|
||||
for (const [name, map] of Object.entries({ progress, ignored }))
|
||||
data[name as "progress" | "ignored"] = Object.fromEntries(
|
||||
Object.entries(map).filter(([key]) => gameOf(key) === game),
|
||||
);
|
||||
data.daily[revision] = Object.fromEntries(
|
||||
Object.entries(daily).filter(([key]) => gameOf(key) === game),
|
||||
);
|
||||
data.amounts[revision] = Object.fromEntries(
|
||||
Object.entries(amounts).filter(([key]) => gameOf(key) === game),
|
||||
);
|
||||
state.data[id] = data;
|
||||
(state.legacyAccounts as Record<string, string>)[game] = id;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Region } from "../../../shared/schema.ts";
|
||||
import type { View } from "../usePrefs.ts";
|
||||
import { routinesFor, offered } from "../../../shared/routines.ts";
|
||||
import { createAccount, changeAccountRegion } from "./accounts.ts";
|
||||
import type { AccountId, ReaderState, TrackingIntent } from "./schema.ts";
|
||||
export interface SetupDraft {
|
||||
region: Region;
|
||||
interface: "relaxed" | "structured";
|
||||
theme: "dark" | "light" | "system";
|
||||
scheme: string;
|
||||
timezone: string | null;
|
||||
games: string[];
|
||||
tracking: Record<string, TrackingIntent>;
|
||||
showUpcoming: boolean;
|
||||
view: View;
|
||||
/** Asked only when the reader picks Timeline; absent keeps the default look. */
|
||||
timelineLook?: "new" | "classic";
|
||||
}
|
||||
export function setupReader(
|
||||
draft: SetupDraft,
|
||||
existing: ReaderState,
|
||||
at: string,
|
||||
mint: () => AccountId,
|
||||
): ReaderState {
|
||||
const accounts = { ...existing.accounts };
|
||||
for (const game of draft.games) {
|
||||
let a =
|
||||
Object.values(accounts).find((a) => a.game === game && !a.retiredAt) ??
|
||||
createAccount(game, draft.region, at, mint());
|
||||
if (a.region !== draft.region)
|
||||
a = changeAccountRegion(a, draft.region, at, `setup-${at}`);
|
||||
const intent = draft.tracking[game] ?? {
|
||||
events: true,
|
||||
dailies: false,
|
||||
weeklyMonthly: false,
|
||||
cycles: false,
|
||||
};
|
||||
const routines = {
|
||||
...a.routines,
|
||||
[`dailies:${game}`]: {
|
||||
followed: intent.dailies,
|
||||
checklist: intent.dailies,
|
||||
timeline: intent.dailies,
|
||||
},
|
||||
};
|
||||
for (const r of routinesFor(game)) {
|
||||
const followed =
|
||||
offered(r, existing.prefs.standardPulls === true) &&
|
||||
(r.period.unit === "cycle" ? intent.cycles : intent.weeklyMonthly);
|
||||
routines[r.id] = { followed, checklist: followed, timeline: followed };
|
||||
}
|
||||
accounts[a.id] = { ...a, intent, routines };
|
||||
}
|
||||
for (const [id, a] of Object.entries(accounts))
|
||||
if (!draft.games.includes(a.game))
|
||||
accounts[id] = {
|
||||
...a,
|
||||
intent: {
|
||||
events: false,
|
||||
dailies: false,
|
||||
weeklyMonthly: false,
|
||||
cycles: false,
|
||||
},
|
||||
routines: Object.fromEntries(
|
||||
Object.entries(a.routines).map(([id, p]) => [
|
||||
id,
|
||||
{ ...p, followed: false },
|
||||
]),
|
||||
),
|
||||
};
|
||||
return {
|
||||
...existing,
|
||||
accounts,
|
||||
prefs: {
|
||||
...existing.prefs,
|
||||
region: draft.region,
|
||||
interface: draft.interface,
|
||||
theme: draft.theme,
|
||||
scheme: draft.scheme,
|
||||
timezone: draft.timezone,
|
||||
showUpcoming: draft.showUpcoming,
|
||||
view: draft.view,
|
||||
...(draft.view === "timeline" && draft.timelineLook
|
||||
? { timelineLook: draft.timelineLook }
|
||||
: {}),
|
||||
onboarded: true,
|
||||
redesignTourSeen: "2026-10-redesign",
|
||||
hiddenGames: Object.values(accounts)
|
||||
.map((a) => a.game)
|
||||
.filter((g) => !draft.games.includes(g)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { applyReaderAliases } from "./aliases.ts";
|
||||
import { archiveOutcome, restoreArchive, reconcileArchive } from "./archive.ts";
|
||||
import {
|
||||
type Account,
|
||||
type ArchiveRecord,
|
||||
type ReaderState,
|
||||
type SeenEvent,
|
||||
emptyAccountData,
|
||||
pairKey,
|
||||
} from "./schema.ts";
|
||||
export type ReaderAction = { at: string; opId: string } & (
|
||||
| {
|
||||
type: "outcome";
|
||||
accountId: string;
|
||||
eventId: string;
|
||||
outcome: "done" | "ignored" | "unfinished";
|
||||
}
|
||||
| { type: "restore"; accountId: string; eventId: string }
|
||||
| { type: "aliases"; table: Record<string, string> }
|
||||
| { type: "reconcile" }
|
||||
| { type: "prefs"; patch: Record<string, unknown> }
|
||||
| {
|
||||
type: "progress";
|
||||
accountId: string;
|
||||
itemId: string;
|
||||
patch: Record<string, unknown>;
|
||||
}
|
||||
| { type: "ignore"; accountId: string; itemId: string; ignored: boolean }
|
||||
| {
|
||||
type: "day";
|
||||
accountId: string;
|
||||
revisionId: string;
|
||||
itemId: string;
|
||||
day: string;
|
||||
checked: boolean;
|
||||
}
|
||||
| {
|
||||
type: "amount";
|
||||
accountId: string;
|
||||
revisionId: string;
|
||||
itemId: string;
|
||||
period: string;
|
||||
value: number | null;
|
||||
}
|
||||
| { type: "account"; account: Account; baseAccount?: Account }
|
||||
| {
|
||||
type: "archive";
|
||||
record: ArchiveRecord | null;
|
||||
accountId: string;
|
||||
eventId: string;
|
||||
}
|
||||
| { type: "seenEvents"; records: SeenEvent[] }
|
||||
| {
|
||||
type: "record";
|
||||
collection: "customGames" | "customEvents" | "tasks";
|
||||
id: string;
|
||||
value: unknown;
|
||||
}
|
||||
| { type: "seenBatch"; entries: { id: string; ids: string[] }[] }
|
||||
| { type: "seen"; kind: "codeSeen" | "eventSeen"; id: string; ids: string[] }
|
||||
| { type: "redeem"; accountId: string; codeId: string; redeemed: boolean }
|
||||
);
|
||||
export function actionFields(action: ReaderAction): string[] {
|
||||
if (action.type === "outcome" || action.type === "restore")
|
||||
return [JSON.stringify(["archive", action.accountId, action.eventId])];
|
||||
if (action.type === "prefs")
|
||||
return Object.keys(action.patch).map((k) => JSON.stringify(["prefs", k]));
|
||||
if (action.type === "progress")
|
||||
return Object.keys(action.patch).map((k) =>
|
||||
JSON.stringify([action.accountId, "progress", action.itemId, k]),
|
||||
);
|
||||
if (action.type === "day")
|
||||
return [
|
||||
JSON.stringify([
|
||||
action.accountId,
|
||||
action.revisionId,
|
||||
action.itemId,
|
||||
action.day,
|
||||
]),
|
||||
];
|
||||
if (action.type === "amount")
|
||||
return [
|
||||
JSON.stringify([
|
||||
action.accountId,
|
||||
action.revisionId,
|
||||
action.itemId,
|
||||
action.period,
|
||||
]),
|
||||
];
|
||||
if (action.type === "ignore")
|
||||
return [JSON.stringify([action.accountId, "ignore", action.itemId])];
|
||||
if (action.type === "account")
|
||||
return [JSON.stringify(["account", action.account.id])];
|
||||
if (action.type === "archive")
|
||||
return [JSON.stringify(["archive", action.accountId, action.eventId])];
|
||||
if (action.type === "record")
|
||||
return [JSON.stringify([action.collection, action.id])];
|
||||
if (action.type === "redeem")
|
||||
return [JSON.stringify(["redeem", action.accountId, action.codeId])];
|
||||
return [];
|
||||
}
|
||||
export function reduceReader(
|
||||
state: ReaderState,
|
||||
action: ReaderAction,
|
||||
): ReaderState {
|
||||
const operations = Array.isArray(state.operations)
|
||||
? (state.operations as string[])
|
||||
: [];
|
||||
if (operations.includes(action.opId)) return state;
|
||||
const times = (state.fieldTimes ?? {}) as Record<string, string>;
|
||||
const fields = actionFields(action);
|
||||
if (fields.some((k) => (times[k] ?? "") > action.at))
|
||||
throw new Error("conflict");
|
||||
const next: ReaderState = {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
operations: [...operations.slice(-1023), action.opId],
|
||||
fieldTimes: {
|
||||
...times,
|
||||
...Object.fromEntries(fields.map((k) => [k, action.at])),
|
||||
},
|
||||
};
|
||||
if (action.type === "aliases") return applyReaderAliases(next, action.table);
|
||||
if (action.type === "outcome")
|
||||
return archiveOutcome(
|
||||
next,
|
||||
action.accountId,
|
||||
action.eventId,
|
||||
action.outcome,
|
||||
action.at,
|
||||
);
|
||||
if (action.type === "restore")
|
||||
return restoreArchive(next, action.accountId, action.eventId);
|
||||
if (action.type === "reconcile")
|
||||
return reconcileArchive(next, [], Date.parse(action.at));
|
||||
if (action.type === "prefs") next.prefs = { ...state.prefs, ...action.patch };
|
||||
else if (action.type === "account") {
|
||||
let account = action.account;
|
||||
const old = state.accounts[account.id],
|
||||
base = action.baseAccount;
|
||||
if (old && base) {
|
||||
account = { ...old };
|
||||
for (const key of new Set([
|
||||
...Object.keys(base),
|
||||
...Object.keys(action.account),
|
||||
])) {
|
||||
const value = action.account[key];
|
||||
if (JSON.stringify(value) === JSON.stringify(base[key])) continue;
|
||||
if (key === "routines") {
|
||||
const routines = { ...old.routines };
|
||||
for (const id of new Set([
|
||||
...Object.keys(base.routines),
|
||||
...Object.keys(action.account.routines),
|
||||
]))
|
||||
if (
|
||||
JSON.stringify(base.routines[id]) !==
|
||||
JSON.stringify(action.account.routines[id])
|
||||
) {
|
||||
const changed = action.account.routines[id];
|
||||
if (changed) {
|
||||
const placement = { ...(old.routines[id] ?? changed) };
|
||||
for (const field of new Set([
|
||||
...Object.keys(base.routines[id] ?? {}),
|
||||
...Object.keys(changed),
|
||||
])) {
|
||||
if (
|
||||
JSON.stringify(changed[field]) ===
|
||||
JSON.stringify(base.routines[id]?.[field])
|
||||
)
|
||||
continue;
|
||||
if (changed[field] === undefined) delete placement[field];
|
||||
else placement[field] = changed[field];
|
||||
}
|
||||
routines[id] = placement;
|
||||
} else delete routines[id];
|
||||
}
|
||||
account.routines = routines;
|
||||
} else if (key === "clocks") {
|
||||
account.clocks = [
|
||||
...old.clocks,
|
||||
...action.account.clocks.filter(
|
||||
(c) =>
|
||||
!base.clocks.some((b) => b.id === c.id) &&
|
||||
!old.clocks.some((b) => b.id === c.id),
|
||||
),
|
||||
];
|
||||
} else if (value === undefined) delete account[key];
|
||||
else account[key] = value;
|
||||
}
|
||||
}
|
||||
next.accounts = { ...state.accounts, [account.id]: account };
|
||||
next.data = {
|
||||
...state.data,
|
||||
[action.account.id]: state.data[action.account.id] ?? emptyAccountData(),
|
||||
};
|
||||
} else if (action.type === "record") {
|
||||
const values = { ...state[action.collection] };
|
||||
if (action.value === null) delete values[action.id];
|
||||
else values[action.id] = action.value;
|
||||
next[action.collection] = values;
|
||||
} else if (action.type === "archive") {
|
||||
const values = { ...state.archive };
|
||||
const key = pairKey(action.accountId, action.eventId);
|
||||
if (action.record === null) delete values[key];
|
||||
else values[key] = action.record;
|
||||
next.archive = values;
|
||||
} else if (action.type === "seenEvents") {
|
||||
next.seenEvents = { ...state.seenEvents };
|
||||
for (const r of action.records) {
|
||||
const key = pairKey(r.accountId, r.event.id),
|
||||
old = next.seenEvents[key];
|
||||
next.seenEvents[key] = {
|
||||
...r,
|
||||
firstSeenAt: old?.firstSeenAt ?? r.firstSeenAt,
|
||||
};
|
||||
}
|
||||
} else if (action.type === "seenBatch") {
|
||||
next.eventSeen = { ...state.eventSeen };
|
||||
for (const e of action.entries)
|
||||
next.eventSeen[e.id] = [
|
||||
...new Set([...(state.eventSeen[e.id] ?? []), ...e.ids]),
|
||||
];
|
||||
} else if (action.type === "seen") {
|
||||
next[action.kind] = {
|
||||
...state[action.kind],
|
||||
[action.id]: [
|
||||
...new Set([...(state[action.kind][action.id] ?? []), ...action.ids]),
|
||||
],
|
||||
};
|
||||
} else if (action.type === "redeem") {
|
||||
const values = { ...state.codeRedeemed[action.accountId] };
|
||||
if (action.redeemed) values[action.codeId] = action.at;
|
||||
else delete values[action.codeId];
|
||||
next.codeRedeemed = { ...state.codeRedeemed, [action.accountId]: values };
|
||||
} else {
|
||||
const old = state.data[action.accountId];
|
||||
if (!old || !state.accounts[action.accountId]) throw new Error("invalid");
|
||||
const data = { ...old };
|
||||
if (action.type === "progress") {
|
||||
const raw = old.progress[action.itemId];
|
||||
const value =
|
||||
typeof raw === "object" && raw !== null
|
||||
? ({ ...raw } as Record<string, unknown>)
|
||||
: {};
|
||||
for (const [k, v] of Object.entries(action.patch)) {
|
||||
if (v === undefined || v === null) delete value[k];
|
||||
else value[k] = v;
|
||||
}
|
||||
data.progress = {
|
||||
...old.progress,
|
||||
[action.itemId]: { ...value, at: action.at },
|
||||
};
|
||||
} else if (action.type === "ignore") {
|
||||
data.ignored = { ...old.ignored };
|
||||
if (action.ignored) data.ignored[action.itemId] = { at: action.at };
|
||||
else delete data.ignored[action.itemId];
|
||||
} else if (action.type === "day") {
|
||||
if (
|
||||
!state.accounts[action.accountId]!.clocks.some(
|
||||
(c) => c.id === action.revisionId,
|
||||
)
|
||||
)
|
||||
throw new Error("invalid");
|
||||
const revision = old.daily[action.revisionId] ?? {};
|
||||
const entry = revision[action.itemId] as { days?: string[] } | undefined;
|
||||
const days = new Set(Array.isArray(entry?.days) ? entry.days : []);
|
||||
if (action.checked) days.add(action.day);
|
||||
else days.delete(action.day);
|
||||
data.daily = {
|
||||
...old.daily,
|
||||
[action.revisionId]: {
|
||||
...revision,
|
||||
[action.itemId]: { ...entry, days: [...days].sort(), at: action.at },
|
||||
},
|
||||
};
|
||||
} else if (action.type === "amount") {
|
||||
if (
|
||||
!state.accounts[action.accountId]!.clocks.some(
|
||||
(c) => c.id === action.revisionId,
|
||||
)
|
||||
)
|
||||
throw new Error("invalid");
|
||||
const revision = old.amounts[action.revisionId] ?? {};
|
||||
const log = {
|
||||
...((revision[action.itemId] as Record<string, unknown>) ?? {}),
|
||||
};
|
||||
if (action.value === null) delete log[action.period];
|
||||
else log[action.period] = { value: action.value, at: action.at };
|
||||
data.amounts = {
|
||||
...old.amounts,
|
||||
[action.revisionId]: { ...revision, [action.itemId]: log },
|
||||
};
|
||||
}
|
||||
next.data = { ...state.data, [action.accountId]: data };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { parseReaderExport, buildReaderExport } from "./export.ts";
|
||||
import { READER_KEYS, type SaveResult } from "./schema.ts";
|
||||
import { readLegacy, tryWriteJson } from "./store.ts";
|
||||
export function finishPendingRestore(storage: Storage): SaveResult {
|
||||
try {
|
||||
const raw = storage.getItem(READER_KEYS.pending);
|
||||
if (raw === null) return { ok: true };
|
||||
const pending = JSON.parse(raw) as { payload?: unknown };
|
||||
const reader = parseReaderExport(pending.payload);
|
||||
if (!reader) return { ok: false, reason: "invalid" };
|
||||
const result = tryWriteJson(storage, READER_KEYS.reader, reader);
|
||||
if (!result.ok) return result;
|
||||
storage.removeItem(READER_KEYS.pending);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, reason: "unavailable" };
|
||||
}
|
||||
}
|
||||
export function prepareRestore(
|
||||
storage: Storage,
|
||||
payload: unknown,
|
||||
at: string,
|
||||
): SaveResult {
|
||||
const reader = parseReaderExport(payload);
|
||||
if (!reader) return { ok: false, reason: "invalid" };
|
||||
try {
|
||||
const oldRaw = storage.getItem(READER_KEYS.reader);
|
||||
if (oldRaw === null) return { ok: false, reason: "invalid" };
|
||||
const before = parseReaderExport({
|
||||
format: "gacha-tracker-export",
|
||||
version: 2,
|
||||
reader: JSON.parse(oldRaw),
|
||||
});
|
||||
if (!before) return { ok: false, reason: "invalid" };
|
||||
const recovery = tryWriteJson(
|
||||
storage,
|
||||
READER_KEYS.recovery,
|
||||
buildReaderExport(before, readLegacy(storage), at),
|
||||
);
|
||||
if (!recovery.ok) return recovery;
|
||||
const prepared = tryWriteJson(storage, READER_KEYS.pending, {
|
||||
at,
|
||||
payload: buildReaderExport(reader, {}, at),
|
||||
});
|
||||
if (!prepared.ok) return prepared;
|
||||
return finishPendingRestore(storage);
|
||||
} catch {
|
||||
return { ok: false, reason: "unavailable" };
|
||||
}
|
||||
}
|
||||
export function undoRestore(storage: Storage): SaveResult {
|
||||
try {
|
||||
const raw = storage.getItem(READER_KEYS.recovery);
|
||||
if (raw === null) return { ok: false, reason: "invalid" };
|
||||
return prepareRestore(storage, JSON.parse(raw), new Date().toISOString());
|
||||
} catch {
|
||||
return { ok: false, reason: "unavailable" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { resolveDaily } from "../../../shared/daily.ts";
|
||||
import { readGoal, goalStatus } from "../../../shared/goal.ts";
|
||||
import { readerClockFor } from "../../../shared/readerClock.ts";
|
||||
import { recordOf } from "./actions.ts";
|
||||
import { isCustomGameId } from "../../../shared/custom.ts";
|
||||
import { metaFor } from "../../../shared/games.ts";
|
||||
import { routinesFor, offered } from "../../../shared/routines.ts";
|
||||
import { type Task, TaskSchema, taskStatus } from "../../../shared/task.ts";
|
||||
import { readableAmounts } from "../useAmounts.ts";
|
||||
import {
|
||||
resolveReaderClock,
|
||||
readerDayKey,
|
||||
type ReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import { resetHourFor, serverOffsetUtc } from "../../../shared/time.ts";
|
||||
import type { GoalStatus } from "../../../shared/goal.ts";
|
||||
import type { Account, ReaderState, RoutinePlacement } from "./schema.ts";
|
||||
export interface RoutineRow {
|
||||
id: string;
|
||||
accountId: string;
|
||||
game: string;
|
||||
title: string;
|
||||
cadence: "day" | "week" | "month" | "cycle";
|
||||
status: GoalStatus;
|
||||
placement: RoutinePlacement;
|
||||
clock: ReaderClock;
|
||||
days: string[];
|
||||
source: string | null;
|
||||
task: Task | null;
|
||||
}
|
||||
export function placementFor(account: Account, id: string): RoutinePlacement {
|
||||
return (
|
||||
account.routines[id] ?? {
|
||||
followed: false,
|
||||
checklist: false,
|
||||
timeline: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
export function routineRows(
|
||||
state: ReaderState,
|
||||
accountId: string,
|
||||
now: number,
|
||||
): RoutineRow[] {
|
||||
const account = state.accounts[accountId],
|
||||
data = state.data[accountId];
|
||||
if (!account || !data) return [];
|
||||
const clock = resolveReaderClock(account, now),
|
||||
today = readerDayKey(now, clock);
|
||||
const daysFor = (id: string): string[] => {
|
||||
const raw = data.daily[clock.revision]?.[id] as
|
||||
| { days?: unknown }
|
||||
| undefined;
|
||||
return Array.isArray(raw?.days)
|
||||
? raw.days.filter((d): d is string => typeof d === "string")
|
||||
: [];
|
||||
};
|
||||
const rows: RoutineRow[] = [];
|
||||
if (!isCustomGameId(account.game)) {
|
||||
const id = `dailies:${account.game}`,
|
||||
days = daysFor(id),
|
||||
count = days.includes(today) ? 1 : 0;
|
||||
rows.push({
|
||||
id,
|
||||
accountId,
|
||||
game: account.game,
|
||||
title: metaFor(account.game, {}).dailyTasks,
|
||||
cadence: "day",
|
||||
status: {
|
||||
scope: "event",
|
||||
measure: "days",
|
||||
periodStart: today,
|
||||
required: 1,
|
||||
count,
|
||||
met: count === 1,
|
||||
needed: 1 - count,
|
||||
open: 1 - count,
|
||||
shortfall: false,
|
||||
},
|
||||
placement: placementFor(account, id),
|
||||
clock,
|
||||
days,
|
||||
source: null,
|
||||
task: null,
|
||||
});
|
||||
}
|
||||
const copied = new Set(
|
||||
Object.entries(state.tasks)
|
||||
.filter(([id]) => account.routines[id]?.followed)
|
||||
.map(([, v]) => (v as { from?: string })?.from),
|
||||
);
|
||||
const builtins = routinesFor(account.game).filter(
|
||||
(r) => !copied.has(r.id) && offered(r, state.prefs.standardPulls === true),
|
||||
);
|
||||
const custom = Object.values(state.tasks).flatMap((v) => {
|
||||
const p = TaskSchema.safeParse(v);
|
||||
return p.success && p.data.game === account.game ? [p.data] : [];
|
||||
});
|
||||
for (const task of [...builtins, ...custom]) {
|
||||
const fixed = "clock" in task ? task.clock : undefined;
|
||||
const own = "resetHour" in task ? task.resetHour : undefined;
|
||||
const timing: ReaderClock = fixed
|
||||
? {
|
||||
revision: clock.revision,
|
||||
region: fixed,
|
||||
offsetUtc: serverOffsetUtc(fixed, account.game),
|
||||
resetHour: resetHourFor(fixed, account.game),
|
||||
resetMinute: 0,
|
||||
}
|
||||
: own !== undefined
|
||||
? { ...clock, resetHour: own, resetMinute: 0 }
|
||||
: clock;
|
||||
const days = daysFor(task.id);
|
||||
const amounts = readableAmounts(
|
||||
data.amounts[clock.revision]?.[task.id] as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
);
|
||||
rows.push({
|
||||
id: task.id,
|
||||
accountId,
|
||||
game: account.game,
|
||||
title: task.title,
|
||||
cadence: task.period.unit,
|
||||
status: taskStatus({
|
||||
task,
|
||||
region: account.region,
|
||||
now,
|
||||
logged: days,
|
||||
amounts,
|
||||
timing,
|
||||
}),
|
||||
placement: placementFor(account, task.id),
|
||||
clock: timing,
|
||||
days,
|
||||
source: "source" in task ? task.source : (task.link ?? null),
|
||||
task: TaskSchema.safeParse(task).success ? (task as Task) : null,
|
||||
});
|
||||
}
|
||||
for (const seen of Object.values(state.seenEvents).filter(
|
||||
(s) => s.accountId === accountId,
|
||||
)) {
|
||||
const event = seen.event,
|
||||
p = recordOf(data.progress[event.id]),
|
||||
goal = readGoal(p.goal),
|
||||
window = readerClockFor(event, account, now);
|
||||
if (
|
||||
!window.live ||
|
||||
state.archive[JSON.stringify([accountId, event.id])] ||
|
||||
p.status === "done" ||
|
||||
data.ignored[event.id] ||
|
||||
!(
|
||||
goal ||
|
||||
resolveDaily(
|
||||
event,
|
||||
typeof p.daily === "boolean" ? p.daily : undefined,
|
||||
state.prefs.detectDaily === true,
|
||||
)
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const days = daysFor(event.id),
|
||||
count = days.includes(today) ? 1 : 0;
|
||||
const status = goal
|
||||
? goalStatus({
|
||||
goal,
|
||||
startsMs: window.startsMs,
|
||||
endsMs: window.endsMs,
|
||||
region: account.region,
|
||||
game: account.game,
|
||||
now,
|
||||
logged: days,
|
||||
amounts: readableAmounts(
|
||||
data.amounts[clock.revision]?.[event.id] as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
),
|
||||
timing: clock,
|
||||
})
|
||||
: {
|
||||
scope: "event" as const,
|
||||
measure: "days" as const,
|
||||
periodStart: today,
|
||||
required: 1,
|
||||
count,
|
||||
met: count === 1,
|
||||
needed: 1 - count,
|
||||
open: 1 - count,
|
||||
shortfall: false,
|
||||
};
|
||||
rows.push({
|
||||
id: event.id,
|
||||
accountId,
|
||||
game: account.game,
|
||||
title: event.title,
|
||||
cadence: "day",
|
||||
status,
|
||||
placement: account.routines[event.id] ?? {
|
||||
followed: true,
|
||||
checklist: true,
|
||||
timeline: false,
|
||||
},
|
||||
clock,
|
||||
days,
|
||||
source: event.sourceUrl,
|
||||
task: null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { z } from "zod";
|
||||
import type { DisplayEvent } from "../../../shared/custom.ts";
|
||||
import { GachaEvent, Region } from "../../../shared/schema.ts";
|
||||
const record = z.record(z.unknown());
|
||||
export const AccountIdSchema = z.string().regex(/^account:[a-zA-Z0-9-]+$/);
|
||||
export type AccountId = z.infer<typeof AccountIdSchema>;
|
||||
export const ClockRevisionSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
effectiveAt: z.string().datetime(),
|
||||
region: Region,
|
||||
reset: z
|
||||
.object({
|
||||
hour: z.number().int().min(0).max(23),
|
||||
minute: z.number().int().min(0).max(59),
|
||||
})
|
||||
.nullable(),
|
||||
automaticBase: z.object({
|
||||
offsetUtc: z.number().min(-14).max(14),
|
||||
hour: z.number().int().min(0).max(23),
|
||||
minute: z.number().int().min(0).max(59),
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
export type ClockRevision = z.infer<typeof ClockRevisionSchema>;
|
||||
export const TrackingIntentSchema = z
|
||||
.object({
|
||||
events: z.boolean(),
|
||||
dailies: z.boolean(),
|
||||
weeklyMonthly: z.boolean(),
|
||||
cycles: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
export type TrackingIntent = z.infer<typeof TrackingIntentSchema>;
|
||||
export const RoutinePlacementSchema = z
|
||||
.object({
|
||||
followed: z.boolean(),
|
||||
checklist: z.boolean(),
|
||||
timeline: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
export type RoutinePlacement = z.infer<typeof RoutinePlacementSchema>;
|
||||
export const AccountSchema = z
|
||||
.object({
|
||||
id: AccountIdSchema,
|
||||
game: z.string().min(1),
|
||||
region: Region,
|
||||
intent: TrackingIntentSchema,
|
||||
routines: z.record(RoutinePlacementSchema),
|
||||
clocks: z.array(ClockRevisionSchema).min(1),
|
||||
retiredAt: z.string().datetime().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type Account = z.infer<typeof AccountSchema>;
|
||||
export const AccountDataSchema = z
|
||||
.object({
|
||||
progress: record,
|
||||
ignored: record,
|
||||
daily: z.record(record),
|
||||
amounts: z.record(record),
|
||||
})
|
||||
.passthrough();
|
||||
export type AccountData = z.infer<typeof AccountDataSchema>;
|
||||
export const DisplayEventSchema = z.custom<DisplayEvent>((value) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof v.game !== "string" ||
|
||||
v.game.length === 0 ||
|
||||
!(v.sourceUrl === null || typeof v.sourceUrl === "string")
|
||||
)
|
||||
return false;
|
||||
// Validate the existing timestamp/precision invariants without widening the wire feed schema.
|
||||
return GachaEvent.safeParse({
|
||||
...v,
|
||||
game: "genshin",
|
||||
sourceUrl: v.sourceUrl ?? "https://reader.invalid/",
|
||||
}).success;
|
||||
});
|
||||
export const ArchiveRecordSchema = z
|
||||
.object({
|
||||
accountId: AccountIdSchema,
|
||||
event: DisplayEventSchema,
|
||||
outcome: z.enum(["done", "ignored", "unfinished"]),
|
||||
outcomeAt: z.string().datetime(),
|
||||
resolvedEnd: z.number().finite().nullable(),
|
||||
clockRevision: z.string(),
|
||||
progress: z.unknown(),
|
||||
days: z.array(z.string()),
|
||||
amounts: record,
|
||||
})
|
||||
.passthrough();
|
||||
export type ArchiveRecord = z.infer<typeof ArchiveRecordSchema>;
|
||||
export const SeenEventSchema = z
|
||||
.object({
|
||||
accountId: AccountIdSchema,
|
||||
event: DisplayEventSchema,
|
||||
firstSeenAt: z.string().datetime(),
|
||||
lastSeenAt: z.string().datetime(),
|
||||
clockRevision: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
export type SeenEvent = z.infer<typeof SeenEventSchema>;
|
||||
export const ReaderStateSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
revision: z.number().int().nonnegative(),
|
||||
migratedAt: z.string().datetime(),
|
||||
legacyDigest: z.string(),
|
||||
accounts: z.record(AccountSchema),
|
||||
data: z.record(AccountDataSchema),
|
||||
archive: z.record(ArchiveRecordSchema),
|
||||
seenEvents: z.record(SeenEventSchema),
|
||||
prefs: record,
|
||||
customGames: record,
|
||||
customEvents: record,
|
||||
tasks: record,
|
||||
codeSeen: z.record(z.array(z.string())),
|
||||
codeRedeemed: z.record(z.record(z.string())),
|
||||
eventSeen: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((s, ctx) => {
|
||||
const invalid = (message: string) =>
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
||||
for (const [id, a] of Object.entries(s.accounts)) {
|
||||
if (id !== a.id || !s.data[id]) invalid("Account identity/data mismatch");
|
||||
const ids = new Set(a.clocks.map((c) => c.id));
|
||||
if (ids.size !== a.clocks.length) invalid("Duplicate clock revision");
|
||||
if (!a.clocks.some((c) => c.historicalOnly !== true))
|
||||
invalid("Missing active clock");
|
||||
const data = s.data[id];
|
||||
if (
|
||||
data &&
|
||||
[...Object.keys(data.daily), ...Object.keys(data.amounts)].some(
|
||||
(r) => !ids.has(r),
|
||||
)
|
||||
)
|
||||
invalid("Unknown log clock revision");
|
||||
}
|
||||
for (const id of Object.keys(s.data))
|
||||
if (!s.accounts[id]) invalid("Orphan account data");
|
||||
for (const collection of [s.archive, s.seenEvents])
|
||||
for (const [key, r] of Object.entries(collection)) {
|
||||
const a = s.accounts[r.accountId];
|
||||
if (
|
||||
!a ||
|
||||
a.game !== r.event.game ||
|
||||
key !== pairKey(r.accountId, r.event.id) ||
|
||||
!a.clocks.some((c) => c.id === r.clockRevision)
|
||||
)
|
||||
invalid("Invalid event account/clock reference");
|
||||
}
|
||||
for (const id of Object.keys(s.codeRedeemed))
|
||||
if (!s.accounts[id]) invalid("Orphan code redemption");
|
||||
});
|
||||
export type ReaderState = z.infer<typeof ReaderStateSchema>;
|
||||
export type SaveResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "unavailable" | "quota" | "invalid" | "conflict" };
|
||||
export const READER_KEYS = {
|
||||
reader: "gacha-tracker:v2:reader",
|
||||
migrationRecovery: "gacha-tracker:v2:migration-recovery",
|
||||
pending: "gacha-tracker:v2:restore-pending",
|
||||
backups: "gacha-tracker:v2:backups",
|
||||
/** Backups the reader made with Back up now; never pruned by `retainBackups`. */
|
||||
manualBackups: "gacha-tracker:v2:manual-backups",
|
||||
recovery: "gacha-tracker:v2:restore-recovery",
|
||||
} as const;
|
||||
export function emptyAccountData(): AccountData {
|
||||
return { progress: {}, ignored: {}, daily: {}, amounts: {} };
|
||||
}
|
||||
export function pairKey(accountId: string, eventId: string): string {
|
||||
return JSON.stringify([accountId, eventId]);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReaderState } from "./schema.ts";
|
||||
|
||||
/**
|
||||
* Whether a game is tracked on more than one server, so a row has to say which.
|
||||
* With one server the name is noise: every row of that game is on it.
|
||||
* Archive views pass `retired` so records from a dropped server keep their label.
|
||||
*/
|
||||
export function manyServers(
|
||||
state: ReaderState,
|
||||
game: string,
|
||||
retired = false,
|
||||
): boolean {
|
||||
return (
|
||||
Object.values(state.accounts).filter(
|
||||
(a) => a.game === game && (retired || !a.retiredAt),
|
||||
).length > 1
|
||||
);
|
||||
}
|
||||
|
||||
/** `" · asia"` when the game has several servers, otherwise nothing. */
|
||||
export function serverSuffix(
|
||||
state: ReaderState,
|
||||
game: string,
|
||||
region: string | null | undefined,
|
||||
retired = false,
|
||||
): string {
|
||||
return region && manyServers(state, game, retired) ? ` · ${region}` : "";
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { withReaderLock } from "./lock.ts";
|
||||
import { refreshAutomaticClocks } from "./clock.ts";
|
||||
import { reconcileLegacy } from "./legacy.ts";
|
||||
import type { LaneId } from "../../../shared/custom.ts";
|
||||
import { migrateLegacy } from "./migration.ts";
|
||||
import {
|
||||
ReaderStateSchema,
|
||||
READER_KEYS,
|
||||
type AccountId,
|
||||
type ReaderState,
|
||||
type SaveResult,
|
||||
} from "./schema.ts";
|
||||
import { reduceReader, type ReaderAction } from "./reducer.ts";
|
||||
export interface ReaderStore {
|
||||
getSnapshot(): ReaderState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
dispatch(action: ReaderAction): Promise<SaveResult>;
|
||||
getError(): SaveResult | null;
|
||||
dispose(): void;
|
||||
}
|
||||
export function tryWriteJson(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): SaveResult {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(value));
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
e instanceof DOMException && e.name === "QuotaExceededError"
|
||||
? "quota"
|
||||
: "unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
export function readLegacy(storage: Storage): Record<string, string> {
|
||||
const raw: Record<string, string> = {};
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key?.startsWith("gacha-tracker:v1:")) {
|
||||
const value = storage.getItem(key);
|
||||
if (value !== null) raw[key] = value;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
export function initializeReader(
|
||||
storage: Storage,
|
||||
games: readonly LaneId[],
|
||||
at: string,
|
||||
mint: () => AccountId,
|
||||
): { ok: true; state: ReaderState } | { ok: false; reason: string } {
|
||||
try {
|
||||
const saved = storage.getItem(READER_KEYS.reader);
|
||||
if (saved !== null) {
|
||||
const parsed = ReaderStateSchema.safeParse(JSON.parse(saved));
|
||||
if (!parsed.success) return { ok: false, reason: "invalid" };
|
||||
const next = refreshAutomaticClocks(
|
||||
reconcileLegacy(parsed.data, readLegacy(storage), at),
|
||||
at,
|
||||
);
|
||||
if (next !== parsed.data) {
|
||||
const result = tryWriteJson(storage, READER_KEYS.reader, next);
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
const raw = readLegacy(storage);
|
||||
const recovered = tryWriteJson(storage, READER_KEYS.migrationRecovery, {
|
||||
at,
|
||||
legacy: raw,
|
||||
});
|
||||
if (!recovered.ok) return recovered;
|
||||
const state = migrateLegacy(raw, games, at, mint);
|
||||
const result = tryWriteJson(storage, READER_KEYS.reader, state);
|
||||
return result.ok ? { ok: true, state } : result;
|
||||
} catch {
|
||||
return { ok: false, reason: "unavailable" };
|
||||
}
|
||||
}
|
||||
export function createReaderStore(
|
||||
storage: Storage,
|
||||
initial: ReaderState,
|
||||
): ReaderStore {
|
||||
let state = initial,
|
||||
error: SaveResult | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let queued = Promise.resolve();
|
||||
const notify = () => {
|
||||
for (const f of listeners) f();
|
||||
};
|
||||
const persisted = (): ReaderState | null => {
|
||||
const raw = storage.getItem(READER_KEYS.reader);
|
||||
if (raw === null) return null;
|
||||
const value = ReaderStateSchema.safeParse(JSON.parse(raw));
|
||||
if (!value.success) throw new Error("invalid");
|
||||
return value.data;
|
||||
};
|
||||
const apply = (action: ReaderAction): SaveResult => {
|
||||
try {
|
||||
const current = persisted() ?? state;
|
||||
const next = reduceReader(current, action);
|
||||
if (next === current) {
|
||||
state = current;
|
||||
notify();
|
||||
return { ok: true };
|
||||
}
|
||||
const parsed = ReaderStateSchema.safeParse(next);
|
||||
if (!parsed.success) return { ok: false, reason: "invalid" };
|
||||
const result = tryWriteJson(storage, READER_KEYS.reader, next);
|
||||
if (!result.ok) return result;
|
||||
const confirmed = persisted();
|
||||
if (
|
||||
confirmed?.revision !== next.revision ||
|
||||
!(confirmed.operations as string[] | undefined)?.includes(action.opId)
|
||||
)
|
||||
return { ok: false, reason: "conflict" };
|
||||
state = next;
|
||||
notify();
|
||||
return result;
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
e instanceof Error && e.message === "conflict"
|
||||
? "conflict"
|
||||
: e instanceof Error && e.message === "invalid"
|
||||
? "invalid"
|
||||
: "unavailable",
|
||||
};
|
||||
}
|
||||
};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== READER_KEYS.reader) return;
|
||||
try {
|
||||
const next = persisted();
|
||||
if (next) {
|
||||
state = next;
|
||||
notify();
|
||||
}
|
||||
} catch {
|
||||
error = { ok: false, reason: "invalid" };
|
||||
notify();
|
||||
}
|
||||
};
|
||||
if (typeof window !== "undefined")
|
||||
window.addEventListener("storage", onStorage);
|
||||
return {
|
||||
getSnapshot: () => state,
|
||||
getError: () => error,
|
||||
subscribe: (f) => {
|
||||
listeners.add(f);
|
||||
return () => {
|
||||
listeners.delete(f);
|
||||
};
|
||||
},
|
||||
dispatch: (input) => {
|
||||
const action: ReaderAction =
|
||||
input.type === "account" && state.accounts[input.account.id]
|
||||
? { ...input, baseAccount: state.accounts[input.account.id]! }
|
||||
: input;
|
||||
const run = async () => {
|
||||
let result: SaveResult;
|
||||
try {
|
||||
result = await withReaderLock(() => apply(action));
|
||||
} catch {
|
||||
result = { ok: false, reason: "unavailable" };
|
||||
}
|
||||
error = result.ok ? null : result;
|
||||
notify();
|
||||
return result;
|
||||
};
|
||||
const result = queued.then(run, run);
|
||||
queued = result.then(() => {});
|
||||
return result;
|
||||
},
|
||||
dispose: () => {
|
||||
if (typeof window !== "undefined")
|
||||
window.removeEventListener("storage", onStorage);
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { freshness, type EventFeed } from "../../../shared/feed.ts";
|
||||
import type { ReaderState } from "./schema.ts";
|
||||
export type UpdateItem =
|
||||
| { kind: "sources"; games: string[] }
|
||||
| { kind: "events"; accountId: string; ids: string[] }
|
||||
| { kind: "codes"; game: string; ids: string[] };
|
||||
export function trackedGames(state: ReaderState): Set<string> {
|
||||
return new Set(
|
||||
Object.values(state.accounts)
|
||||
.filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!(
|
||||
Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(a.game)
|
||||
) &&
|
||||
(a.intent.events ||
|
||||
Object.values(a.routines).some((r) => r.followed)),
|
||||
)
|
||||
.map((a) => a.game),
|
||||
);
|
||||
}
|
||||
export function updateItems(
|
||||
state: ReaderState,
|
||||
feed: EventFeed,
|
||||
now: number,
|
||||
): UpdateItem[] {
|
||||
const items: UpdateItem[] = [],
|
||||
tracked = trackedGames(state);
|
||||
const stale = freshness(feed.sources, now).stale.filter((s) =>
|
||||
tracked.has(s.game),
|
||||
);
|
||||
if (stale.length && !snoozed(state.prefs.staleSnooze, stale, now))
|
||||
items.push({ kind: "sources", games: stale.map((s) => s.game) });
|
||||
for (const a of Object.values(state.accounts)) {
|
||||
if (
|
||||
a.retiredAt ||
|
||||
!tracked.has(a.game) ||
|
||||
!a.intent.events ||
|
||||
!state.eventSeen[a.id]
|
||||
)
|
||||
continue;
|
||||
const ids = feed.events
|
||||
.filter(
|
||||
(e) => e.game === a.game && !state.eventSeen[a.id]!.includes(e.id),
|
||||
)
|
||||
.map((e) => e.id);
|
||||
if (ids.length) items.push({ kind: "events", accountId: a.id, ids });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** The longest a reader can hide the “Event data may be out of date” notice. */
|
||||
export const MAX_SNOOZE_DAYS = 7;
|
||||
/** `prefs.staleSnooze`: hide the source warning for the games stale right now. */
|
||||
export function staleSnooze(games: string[], now: number, days: number) {
|
||||
const d = Math.min(Math.max(days, 0), MAX_SNOOZE_DAYS);
|
||||
return { until: new Date(now + d * 86_400_000).toISOString(), games };
|
||||
}
|
||||
function snoozed(
|
||||
pref: unknown,
|
||||
stale: readonly { game: string }[],
|
||||
now: number,
|
||||
): boolean {
|
||||
if (typeof pref !== "object" || pref === null) return false;
|
||||
const { until, games } = pref as { until?: unknown; games?: unknown };
|
||||
const end = typeof until === "string" ? Date.parse(until) : NaN;
|
||||
// A clock set back cannot stretch the snooze beyond seven days from now.
|
||||
if (!(now < end) || end - now > MAX_SNOOZE_DAYS * 86_400_000) return false;
|
||||
return Array.isArray(games) && stale.every((s) => games.includes(s.game));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||
import type { ReaderStore } from "./store.ts";
|
||||
export const ReaderContext = createContext<ReaderStore | null>(null);
|
||||
export function useReader() {
|
||||
const store = useContext(ReaderContext);
|
||||
if (!store) throw new Error("Reader store missing");
|
||||
const state = useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot,
|
||||
);
|
||||
const saveError = useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getError,
|
||||
store.getError,
|
||||
);
|
||||
return { state, dispatch: store.dispatch, saveError };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AppPage } from "../components/redesign/AppNavigation.tsx";
|
||||
import {
|
||||
SETTINGS_PAGES,
|
||||
type SettingsPage,
|
||||
} from "../components/redesign/SettingsLayout.tsx";
|
||||
|
||||
/**
|
||||
* Where the reader is, kept in the address bar so a reload returns them there.
|
||||
*
|
||||
* `#checklist`, `#timeline`, `#routines`, `#archive`, `#settings/<section>`
|
||||
* and `#settings/games/<game>`. Not a preference: the default view still
|
||||
* decides where a fresh visit with no hash opens.
|
||||
*/
|
||||
export interface Route {
|
||||
page: AppPage;
|
||||
settings?: SettingsPage | undefined;
|
||||
/** A game's settings page, when `settings` is "games". */
|
||||
game?: string | undefined;
|
||||
}
|
||||
|
||||
const PAGE_SLUG: Record<Exclude<AppPage, "settings">, string> = {
|
||||
soon: "checklist",
|
||||
timeline: "timeline",
|
||||
routines: "routines",
|
||||
archive: "archive",
|
||||
};
|
||||
const SECTIONS = new Set<string>([
|
||||
...SETTINGS_PAGES.map(([id]) => id),
|
||||
"games",
|
||||
]);
|
||||
|
||||
export function routeHash(route: Route): string {
|
||||
if (route.page !== "settings") return `#${PAGE_SLUG[route.page]}`;
|
||||
const section = route.settings ?? "appearance";
|
||||
return section === "games" && route.game
|
||||
? `#settings/games/${encodeURIComponent(route.game)}`
|
||||
: `#settings/${section}`;
|
||||
}
|
||||
|
||||
/** The route a hash names, or null when it names none this app knows. */
|
||||
export function parseRoute(hash: string): Route | null {
|
||||
const [head, section, game] = hash.replace(/^#\/?/, "").split("/");
|
||||
const page = (Object.keys(PAGE_SLUG) as (keyof typeof PAGE_SLUG)[]).find(
|
||||
(p) => PAGE_SLUG[p] === head,
|
||||
);
|
||||
if (page) return { page };
|
||||
if (head !== "settings") return null;
|
||||
if (section === undefined || section === "")
|
||||
return { page: "settings", settings: "appearance" };
|
||||
// Default view moved into Appearance; old links still land there.
|
||||
if (section === "general")
|
||||
return { page: "settings", settings: "appearance" };
|
||||
// The Lists options moved into Data & sources.
|
||||
if (section === "lists") return { page: "settings", settings: "sources" };
|
||||
if (!SECTIONS.has(section)) return null;
|
||||
if (section === "games") {
|
||||
// Game order lives in Appearance; a game's own settings need its id.
|
||||
if (game === undefined) return { page: "settings", settings: "appearance" };
|
||||
if (game === "") return null;
|
||||
return {
|
||||
page: "settings",
|
||||
settings: "games",
|
||||
game: decodeURIComponent(game),
|
||||
};
|
||||
}
|
||||
return { page: "settings", settings: section as SettingsPage };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { instantInZone } from "./displayTime.ts";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
asDisplayEvent,
|
||||
@@ -54,11 +55,13 @@ export function readerInstant(
|
||||
date: string,
|
||||
time: string | null,
|
||||
boundary: "start" | "end",
|
||||
timezone?: string | null,
|
||||
): string | null {
|
||||
const wall =
|
||||
time !== null && time !== ""
|
||||
? `${date}T${time}`
|
||||
: `${date}T${boundary === "start" ? "00:00:00" : "23:59:59"}`;
|
||||
if (timezone) return instantInZone(date, time || (boundary === "start" ? "00:00:00" : "23:59:59"), timezone);
|
||||
const ms = Date.parse(wall);
|
||||
if (Number.isNaN(ms)) return null;
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import { hiddenAfter, routinesFor, type Routine } from "../../shared/routines.ts
|
||||
export type View = "soon" | "timeline";
|
||||
|
||||
export interface Prefs {
|
||||
interface?: "relaxed" | "structured";
|
||||
density?: "standard" | "compact";
|
||||
region: Region;
|
||||
/**
|
||||
* Games the reader has switched off.
|
||||
@@ -306,6 +308,8 @@ export function trackRoutinePatch(p: Prefs, routine: Routine, track: boolean): P
|
||||
|
||||
export function defaults(): Prefs {
|
||||
return {
|
||||
interface: "structured",
|
||||
density: "standard",
|
||||
region: guessRegion(),
|
||||
hiddenGames: [],
|
||||
focusGame: null,
|
||||
@@ -406,6 +410,9 @@ export function restorePrefsValue(incoming: unknown): Prefs {
|
||||
...raw,
|
||||
};
|
||||
|
||||
if (result.interface !== "relaxed" && result.interface !== "structured") result.interface = "structured";
|
||||
if (result.density !== "standard" && result.density !== "compact") result.density = "standard";
|
||||
|
||||
// Region
|
||||
if (result.region !== "america" && result.region !== "europe" && result.region !== "asia") {
|
||||
result.region = base.region;
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface TaskDraft {
|
||||
measure: Measure;
|
||||
/** The built-in this copies, when it does (routines.ts). */
|
||||
from?: string | undefined;
|
||||
resetHour?: number | undefined;
|
||||
note?: string | undefined;
|
||||
link?: string | undefined;
|
||||
}
|
||||
|
||||
/** The store as it sits on disk, every record kept whether or not this build reads it. */
|
||||
|
||||
@@ -492,3 +492,5 @@ body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-hairline) transparent;
|
||||
}
|
||||
|
||||
@import "./redesign.css";
|
||||
|
||||
+2
-1
@@ -49,6 +49,7 @@ const BASE = new URL("./", self.registration.scope);
|
||||
const at = (path) => new URL(path, BASE).toString();
|
||||
const SHELL = ["", "index.html", "styles.css", "main.js"].map(at);
|
||||
const FEED = new URL("data/events.v1.json", BASE).pathname;
|
||||
const CODES_FEED = new URL("data/codes.v1.json", BASE).pathname;
|
||||
const FONT_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
|
||||
/** What a page sends to ask a waiting worker to take over now. */
|
||||
const SKIP_WAITING = "skip-waiting";
|
||||
@@ -126,7 +127,7 @@ self.addEventListener("fetch", (event) => {
|
||||
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname === FEED) {
|
||||
if (url.pathname === FEED || url.pathname === CODES_FEED) {
|
||||
event.respondWith(feedFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CodesFeed, RedeemCode } from "../../shared/codes.ts";
|
||||
export function buildCodesFeed(
|
||||
previous: CodesFeed | null,
|
||||
parsed: readonly Omit<RedeemCode, "firstObservedAt">[],
|
||||
at: string,
|
||||
): CodesFeed {
|
||||
const first = new Map(previous?.codes.map((c) => [c.id, c.firstObservedAt]));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
codes: parsed.map((c) => ({
|
||||
...c,
|
||||
firstObservedAt: first.get(c.id) ?? at,
|
||||
})),
|
||||
sources: previous?.sources ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { text, decodeEntities } from "../html.ts";
|
||||
import type { ParseContext } from "../adapters/types.ts";
|
||||
import type { RedeemCode } from "../../shared/codes.ts";
|
||||
function parseLatest(
|
||||
html: string,
|
||||
ctx: ParseContext,
|
||||
): Omit<RedeemCode, "firstObservedAt">[] {
|
||||
const heading = /<h[23]\b[^>]*>\s*Latest Redeem Codes\s*<\/h[23]>/i.exec(
|
||||
html,
|
||||
);
|
||||
if (!heading)
|
||||
throw new Error("The current codes section could not be identified");
|
||||
const after = html.slice(heading.index + heading[0].length),
|
||||
end = after.search(/<h[23]\b/i),
|
||||
section = end < 0 ? after : after.slice(0, end);
|
||||
const table = /<table\b[^>]*>([\s\S]*?)<\/table>/i.exec(section);
|
||||
if (
|
||||
!table ||
|
||||
!/<th\b[^>]*>\s*Redeem Codes\s*<\/th>/i.test(table[0]) ||
|
||||
!/<th\b[^>]*>\s*Rewards\s*<\/th>/i.test(table[0])
|
||||
)
|
||||
throw new Error("The codes table has changed shape");
|
||||
const codes = new Map<string, Omit<RedeemCode, "firstObservedAt">>();
|
||||
for (const tr of table[1]!.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
||||
const cells = [...tr[1]!.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)];
|
||||
if (!cells.length) continue;
|
||||
const input =
|
||||
/<input\b(?=[^>]*\bclass=["'][^"']*a-clipboard__textInput)(?=[^>]*\bvalue=["']([^"']+)["'])[^>]*>/i.exec(
|
||||
cells[0]![1]!,
|
||||
);
|
||||
if (!input) throw new Error("A current-code row has no readable code");
|
||||
const code = decodeEntities(input[1]!).trim();
|
||||
if (!code || code.length > 100) throw new Error("Invalid code text");
|
||||
const reward = cells[1] ? text(cells[1][1]!) : null;
|
||||
const id = JSON.stringify([ctx.game, code]);
|
||||
codes.set(id, {
|
||||
id,
|
||||
game: ctx.game,
|
||||
code,
|
||||
reward: reward || null,
|
||||
expiresAt: null,
|
||||
sourceUrl: ctx.sourceUrl,
|
||||
});
|
||||
}
|
||||
return [...codes.values()];
|
||||
}
|
||||
|
||||
export function parseGame8Codes(
|
||||
html: string,
|
||||
ctx: ParseContext,
|
||||
): Omit<RedeemCode, "firstObservedAt">[] {
|
||||
const codes = new Map(parseLatest(html, ctx).map((c) => [c.id, c]));
|
||||
const heading = /<h2\b[^>]*>\s*[\d.]+ Special Program Codes\s*<\/h2>/i.exec(
|
||||
html,
|
||||
);
|
||||
if (heading) {
|
||||
const after = html.slice(heading.index + heading[0].length),
|
||||
end = after.search(/<h2\b/i),
|
||||
section = end < 0 ? after : after.slice(0, end);
|
||||
const expiry =
|
||||
/cannot be redeemed after\s+([A-Z][a-z]+ \d{1,2}, \d{4})/.exec(
|
||||
text(section),
|
||||
);
|
||||
const expired =
|
||||
expiry &&
|
||||
Number.isFinite(Date.parse(expiry[1]! + " 00:00:00 GMT")) &&
|
||||
Date.parse(ctx.now) >=
|
||||
Date.parse(expiry[1]! + " 00:00:00 GMT") + 2 * 86400000;
|
||||
if (!expired) {
|
||||
for (const table of section.matchAll(
|
||||
/<table\b[^>]*>[\s\S]*?<\/table>/gi,
|
||||
)) {
|
||||
if (!/Global Codes/.test(table[0])) continue;
|
||||
const transformed =
|
||||
"<h3>Latest Redeem Codes</h3>" +
|
||||
table[0].replace("Global Codes", "Redeem Codes");
|
||||
for (const code of parseLatest(transformed, ctx)) {
|
||||
if (
|
||||
expiry &&
|
||||
Number.isFinite(Date.parse(expiry[1]! + " 00:00:00 GMT"))
|
||||
)
|
||||
code.expiresOn = new Date(Date.parse(expiry[1]! + " 00:00:00 GMT"))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
codes.set(code.id, code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...codes.values()];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SIX_HOURS_MS, type Adapter } from "../adapters/types.ts";
|
||||
import { parseGame8Codes } from "./game8.ts";
|
||||
export const CODE_SOURCES = [
|
||||
{
|
||||
id: "genshin-game8-codes",
|
||||
game: "genshin" as const,
|
||||
url: "https://game8.co/games/Genshin-Impact/archives/304759",
|
||||
parserId: "game8-codes",
|
||||
minIntervalMs: SIX_HOURS_MS,
|
||||
priority: 0,
|
||||
parse: parseGame8Codes,
|
||||
},
|
||||
];
|
||||
// Fetch policy only depends on source identity, interval and parse count. Event adapters keep
|
||||
// their GachaEvent return contract; the scheduler can also count code records without faking events.
|
||||
export type RefreshSource = Omit<Adapter, "parse"> & {
|
||||
parse: typeof parseGame8Codes | Adapter["parse"];
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { GameId } from "./schema.ts";
|
||||
import { SourceHealth } from "./feed.ts";
|
||||
export const RedeemCodeSchema = z.object({
|
||||
id: z.string(),
|
||||
game: GameId,
|
||||
code: z.string().min(1).max(100),
|
||||
reward: z.string().max(1000).nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
expiresOn: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
firstObservedAt: z.string().datetime(),
|
||||
sourceUrl: z.string().url(),
|
||||
});
|
||||
export type RedeemCode = z.infer<typeof RedeemCodeSchema>;
|
||||
export const CodesFeedSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
codes: z.array(RedeemCodeSchema),
|
||||
sources: z.array(SourceHealth),
|
||||
});
|
||||
export type CodesFeed = z.infer<typeof CodesFeedSchema>;
|
||||
+19
-15
@@ -1,3 +1,4 @@
|
||||
import type { ResetTiming } from "./time.ts";
|
||||
import type { LaneId } from "./custom.ts";
|
||||
import type { GachaEvent, GameId, Region } from "./schema.ts";
|
||||
import { DAY, resetShiftMs } from "./time.ts";
|
||||
@@ -117,19 +118,19 @@ export function dailyOverride(
|
||||
* generic "what day is it here?" — still gets the regional answer. Anything
|
||||
* that reads or writes a tick should pass it.
|
||||
*/
|
||||
export function dayKey(ms: number, region: Region, game?: LaneId): string {
|
||||
return new Date(ms + resetShiftMs(region, game)).toISOString().slice(0, 10);
|
||||
export function dayKey(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): string {
|
||||
return new Date(ms + resetShiftMs(region, game, timing)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The next reset instant strictly after `ms`. */
|
||||
export function nextResetMs(ms: number, region: Region, game?: LaneId): number {
|
||||
const s = resetShiftMs(region, game);
|
||||
export function nextResetMs(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
const s = resetShiftMs(region, game, timing);
|
||||
return Math.floor((ms + s) / DAY) * DAY + DAY - s;
|
||||
}
|
||||
|
||||
/** How long the reader has left to do today's dailies. */
|
||||
export function msUntilReset(ms: number, region: Region, game?: LaneId): number {
|
||||
return nextResetMs(ms, region, game) - ms;
|
||||
export function msUntilReset(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
return nextResetMs(ms, region, game, timing) - ms;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +145,7 @@ export function dailyDays(
|
||||
endsMs: number | null,
|
||||
region: Region,
|
||||
game?: LaneId,
|
||||
timing?: ResetTiming,
|
||||
): string[] | null {
|
||||
if (endsMs === null) return null;
|
||||
|
||||
@@ -152,11 +154,11 @@ export function dailyDays(
|
||||
// reset: an event ending at 04:00 gives you nothing on that final day.
|
||||
const last = endsMs - 1;
|
||||
let cursor = startsMs;
|
||||
if (last < startsMs) return [dayKey(startsMs, region, game)];
|
||||
if (last < startsMs) return [dayKey(startsMs, region, game, timing)];
|
||||
|
||||
while (cursor <= last && out.length < MAX_DAYS) {
|
||||
out.push(dayKey(cursor, region, game));
|
||||
cursor = nextResetMs(cursor, region, game);
|
||||
out.push(dayKey(cursor, region, game, timing));
|
||||
cursor = nextResetMs(cursor, region, game, timing);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -196,9 +198,10 @@ export function catchUpDays(
|
||||
game: LaneId | undefined,
|
||||
notBefore: number | null,
|
||||
span = CATCH_UP_DAYS,
|
||||
timing?: ResetTiming,
|
||||
): string[] {
|
||||
const today = dayKey(now, region, game);
|
||||
const floor = notBefore === null ? null : dayKey(notBefore, region, game);
|
||||
const today = dayKey(now, region, game, timing);
|
||||
const floor = notBefore === null ? null : dayKey(notBefore, region, game, timing);
|
||||
|
||||
const out: string[] = [];
|
||||
// Walked in day-key space rather than in instants, like `streakOf`: these are
|
||||
@@ -248,10 +251,11 @@ export function dailySummary(input: {
|
||||
game?: LaneId | undefined;
|
||||
now: number;
|
||||
logged: readonly string[];
|
||||
timing?: ResetTiming;
|
||||
}): DailySummary {
|
||||
const { startsMs, endsMs, region, game, now, logged } = input;
|
||||
const days = dailyDays(startsMs, endsMs, region, game);
|
||||
const today = dayKey(now, region, game);
|
||||
const { startsMs, endsMs, region, game, now, logged, timing } = input;
|
||||
const days = dailyDays(startsMs, endsMs, region, game, timing);
|
||||
const today = dayKey(now, region, game, timing);
|
||||
const ticked = new Set(logged);
|
||||
|
||||
const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d));
|
||||
@@ -269,7 +273,7 @@ export function dailySummary(input: {
|
||||
? null
|
||||
: days.filter((d) => d < today && !ticked.has(d)).length,
|
||||
streak: streakOf(logged, today),
|
||||
msUntilReset: msUntilReset(now, region, game),
|
||||
msUntilReset: msUntilReset(now, region, game, timing),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-8
@@ -1,3 +1,4 @@
|
||||
import type { ResetTiming } from "./time.ts";
|
||||
import { z } from "zod";
|
||||
import type { LaneId } from "./custom.ts";
|
||||
import { dailyDays, dayKey } from "./daily.ts";
|
||||
@@ -73,7 +74,7 @@ export function readGoal(stored: unknown): Goal | undefined {
|
||||
|
||||
export interface GoalStatus {
|
||||
/** Month and cycle are a task's period (task.ts); an event's goal is a week or the event. */
|
||||
scope: "week" | "month" | "cycle" | "event";
|
||||
scope: "day" | "week" | "month" | "cycle" | "event";
|
||||
measure: "days" | "amount";
|
||||
/** The period's first game-day: the key an amount is stored under. */
|
||||
periodStart: string;
|
||||
@@ -114,11 +115,12 @@ export function goalStatus(input: {
|
||||
logged: readonly string[];
|
||||
/** This item's entered numbers, for an amount goal. */
|
||||
amounts?: AmountLog | undefined;
|
||||
timing?: ResetTiming;
|
||||
}): GoalStatus {
|
||||
const { goal, startsMs, endsMs, region, game, now, logged, amounts } = input;
|
||||
const today = dayKey(now, region, game);
|
||||
const first = dayKey(startsMs, region, game);
|
||||
const run = dailyDays(startsMs, endsMs, region, game);
|
||||
const { goal, startsMs, endsMs, region, game, now, logged, amounts, timing } = input;
|
||||
const today = dayKey(now, region, game, timing);
|
||||
const first = dayKey(startsMs, region, game, timing);
|
||||
const run = dailyDays(startsMs, endsMs, region, game, timing);
|
||||
|
||||
let scope: string[];
|
||||
let periodStart: string;
|
||||
@@ -174,9 +176,11 @@ export function goalStatus(input: {
|
||||
const days = (n: number) => (n === 1 ? "1 day" : `${n} days`);
|
||||
/** Thousands grouped: 2,100 reads as a number of points, 2100 as a year. */
|
||||
const num = (n: number) => n.toLocaleString("en-US");
|
||||
/** " this week", " this month", " this cycle", or nothing for a whole event. */
|
||||
const within = (s: GoalStatus) => (s.scope === "event" ? "" : ` this ${s.scope}`);
|
||||
const doneLabel = (s: GoalStatus) => (s.scope === "event" ? "goal reached" : `done this ${s.scope}`);
|
||||
/** " today", " this week", " this month", " this cycle", or nothing for a whole event. */
|
||||
const within = (s: GoalStatus) =>
|
||||
s.scope === "event" ? "" : s.scope === "day" ? " today" : ` this ${s.scope}`;
|
||||
const doneLabel = (s: GoalStatus) =>
|
||||
s.scope === "event" ? "goal reached" : s.scope === "day" ? "done today" : `done this ${s.scope}`;
|
||||
|
||||
/**
|
||||
* The checklist's line. An unticked past day is not "missed" here: every day
|
||||
|
||||
@@ -31,6 +31,8 @@ const DayKey = z
|
||||
});
|
||||
|
||||
export const PeriodSchema = z.union([
|
||||
/** Every game-day: a reader's own daily, rolling on its reset. */
|
||||
z.object({ unit: z.literal("day") }),
|
||||
z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }),
|
||||
/** Day of month; 31 means "the last day" in a shorter month. */
|
||||
z.object({ unit: z.literal("month"), startsOn: z.number().int().min(1).max(31) }),
|
||||
@@ -73,7 +75,10 @@ export function periodOf(
|
||||
): { start: string; days: string[] } {
|
||||
let start: string;
|
||||
let next: string;
|
||||
if (period.unit === "week") {
|
||||
if (period.unit === "day") {
|
||||
start = today;
|
||||
next = addDays(today, 1);
|
||||
} else if (period.unit === "week") {
|
||||
start = addDays(today, -((isoWeekday(today) - period.startsOn + 7) % 7));
|
||||
next = addDays(start, 7);
|
||||
} else if (period.unit === "cycle") {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Account, ClockRevision } from "../client/state/reader/schema.ts";
|
||||
import type { Region } from "./schema.ts";
|
||||
import {
|
||||
clockFor,
|
||||
resetHourFor,
|
||||
serverOffsetUtc,
|
||||
type Clockable,
|
||||
type ResetTiming,
|
||||
} from "./time.ts";
|
||||
import { dayKey } from "./daily.ts";
|
||||
export interface ReaderClock extends ResetTiming {
|
||||
revision: string;
|
||||
region: Region;
|
||||
}
|
||||
export function resolveReaderClock(account: Account, at: number): ReaderClock {
|
||||
const ordered = account.clocks
|
||||
.filter((r) => r.historicalOnly !== true)
|
||||
.slice()
|
||||
.sort((a, b) => a.effectiveAt.localeCompare(b.effectiveAt));
|
||||
const r =
|
||||
ordered.filter((r) => Date.parse(r.effectiveAt) <= at).at(-1) ??
|
||||
ordered[0]!;
|
||||
return clockFromRevision(r);
|
||||
}
|
||||
export function clockFromRevision(r: ClockRevision): ReaderClock {
|
||||
return {
|
||||
revision: r.id,
|
||||
region: r.region,
|
||||
offsetUtc: r.automaticBase.offsetUtc,
|
||||
resetHour: r.reset?.hour ?? r.automaticBase.hour,
|
||||
resetMinute: r.reset?.minute ?? r.automaticBase.minute,
|
||||
};
|
||||
}
|
||||
export function appendClockRevision(
|
||||
account: Account,
|
||||
reset: ClockRevision["reset"],
|
||||
region: Region,
|
||||
at: string,
|
||||
id: string,
|
||||
): Account {
|
||||
if (
|
||||
!Number.isFinite(Date.parse(at)) ||
|
||||
account.clocks.some((r) => r.id === id) ||
|
||||
(reset &&
|
||||
(reset.hour < 0 ||
|
||||
reset.hour > 23 ||
|
||||
reset.minute < 0 ||
|
||||
reset.minute > 59 ||
|
||||
!Number.isInteger(reset.hour) ||
|
||||
!Number.isInteger(reset.minute)))
|
||||
)
|
||||
throw new Error("Invalid clock revision");
|
||||
return {
|
||||
...account,
|
||||
region,
|
||||
clocks: [
|
||||
...account.clocks,
|
||||
{
|
||||
id,
|
||||
effectiveAt: at,
|
||||
region,
|
||||
reset,
|
||||
automaticBase: {
|
||||
offsetUtc: serverOffsetUtc(region, account.game),
|
||||
hour: resetHourFor(region, account.game),
|
||||
minute: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export function readerDayKey(at: number, clock: ReaderClock): string {
|
||||
return dayKey(at, clock.region, undefined, clock);
|
||||
}
|
||||
export function readerClockFor(
|
||||
event: Clockable,
|
||||
account: Account,
|
||||
now: number,
|
||||
) {
|
||||
const clock = resolveReaderClock(account, now);
|
||||
return clockFor(event, clock.region, now, clock);
|
||||
}
|
||||
+20
-2
@@ -1,3 +1,4 @@
|
||||
import type { ResetTiming } from "./time.ts";
|
||||
import { z } from "zod";
|
||||
import type { LaneId } from "./custom.ts";
|
||||
import { dayKey } from "./daily.ts";
|
||||
@@ -55,6 +56,20 @@ export const TaskSchema = z.object({
|
||||
measure: MeasureSchema,
|
||||
/** The built-in routine this was copied from, when it was (phase 2b). */
|
||||
from: z.string().optional(),
|
||||
/**
|
||||
* Its own reset hour, in server time, instead of the game's. Moves only this
|
||||
* routine's day boundary; its log stays under the account's clock revision.
|
||||
*/
|
||||
resetHour: z.number().int().min(0).max(23).optional(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
/** Rendered as a link, so only web addresses. */
|
||||
link: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500)
|
||||
.url()
|
||||
.refine((u) => /^https?:\/\//i.test(u))
|
||||
.optional(),
|
||||
at: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
@@ -74,9 +89,10 @@ export function taskStatus(input: {
|
||||
now: number;
|
||||
logged: readonly string[];
|
||||
amounts?: AmountLog | undefined;
|
||||
timing?: ResetTiming;
|
||||
}): GoalStatus {
|
||||
const { task, region, now, logged, amounts } = input;
|
||||
const today = dayKey(now, task.clock ?? region, task.game as LaneId);
|
||||
const today = dayKey(now, task.clock ?? region, task.game as LaneId, task.clock === undefined ? input.timing : undefined);
|
||||
const { start, days } = periodOf(task.period, today);
|
||||
const scope = task.period.unit;
|
||||
const left = days.filter((k) => k >= today);
|
||||
@@ -123,7 +139,9 @@ export function asOfLabel(day: string): string {
|
||||
/** One line saying when it rolls and what it wants. */
|
||||
export function taskCaption(task: Pick<Task, "period" | "measure">): string {
|
||||
const when =
|
||||
task.period.unit === "week"
|
||||
task.period.unit === "day"
|
||||
? "every day"
|
||||
: task.period.unit === "week"
|
||||
? `every week from ${WEEKDAYS[task.period.startsOn - 1]}`
|
||||
: task.period.unit === "month"
|
||||
? `every month from the ${ordinal(task.period.startsOn)}`
|
||||
|
||||
+11
-7
@@ -8,6 +8,8 @@ import type { GameId, Precision } from "./schema.ts";
|
||||
* how long is left, how far through a window we are, and how alarmed to be.
|
||||
*/
|
||||
|
||||
export interface ResetTiming { offsetUtc: number; resetHour: number; resetMinute: number }
|
||||
|
||||
export const MINUTE = 60_000;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
export const DAY = 24 * HOUR;
|
||||
@@ -86,16 +88,16 @@ export function resetHourFor(region: Region, game?: LaneId): number {
|
||||
* one, but it is still the reader's streak moving under them. Treat a change
|
||||
* here as a data change, not a constant.
|
||||
*/
|
||||
export function resetShiftMs(region: Region, game?: LaneId): number {
|
||||
return serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR;
|
||||
export function resetShiftMs(region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
return timing === undefined ? serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR : timing.offsetUtc * HOUR - timing.resetHour * HOUR - timing.resetMinute * MINUTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant the game-day labelled `day` (`YYYY-MM-DD`) opens on — the inverse
|
||||
* of `dayKey`.
|
||||
*/
|
||||
export function dayStartMs(day: string, region: Region, game?: LaneId): number {
|
||||
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game);
|
||||
export function dayStartMs(day: string, region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game, timing);
|
||||
}
|
||||
|
||||
export function guessRegion(
|
||||
@@ -191,11 +193,12 @@ function boundaryMs(
|
||||
precision: Precision,
|
||||
event: Pick<Clockable, "game" | "extractionMethod">,
|
||||
region: Region,
|
||||
timing?: ResetTiming,
|
||||
): number {
|
||||
if (precision !== "day" || event.extractionMethod !== "parser") {
|
||||
return Date.parse(iso);
|
||||
}
|
||||
return dayStartMs(iso.slice(0, 10), region, event.game);
|
||||
return dayStartMs(iso.slice(0, 10), region, event.game, timing);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,10 +311,11 @@ export function clockFor(
|
||||
event: Clockable,
|
||||
region: Region,
|
||||
now: number,
|
||||
timing?: ResetTiming,
|
||||
): EventClock {
|
||||
const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region);
|
||||
const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region, timing);
|
||||
const end = endBoundary(event, region);
|
||||
const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region);
|
||||
const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region, timing);
|
||||
|
||||
const msRemaining = endsMs === null ? null : endsMs - now;
|
||||
const upcoming = now < startsMs;
|
||||
|
||||
Reference in New Issue
Block a user