feat(reader): separate Miliastra and track passes as cycles

Route source-classified activities into an independent Miliastra lane and
show dated battle passes once under Cycles. Genshin's About tab can disable
TCG tracking without deleting progress or history.

Activate routing and presentation together so no intermediate version moves
saved progress while still rendering its old owner. Preserve identities,
clock revisions and deletion timestamps across late writes and backups.
This commit is contained in:
Lucas Winther committed 2026-11-01 04:12:14 +01:00
1 parent 5576e5ad5c
commit b03a6ed2d1
31 files changed
+1127 -52

No files matched your search

+13 -4
View File
@@ -37,6 +37,7 @@ import {
type ReaderEventRow,
} from "./state/reader/eventRows.ts";
import { routineRows } from "./state/reader/routines.ts";
import { displayGameFor, passPlacementId, sourceGameFor } from "../shared/classification.ts";
import { setupReader } from "./state/reader/onboarding.ts";
import { resolveReaderClock, readerClockFor } from "../shared/readerClock.ts";
import {
@@ -144,6 +145,7 @@ export function ReaderApp() {
...new Set([
...feed.sources.map((s) => s.game),
...feed.events.map((e) => e.game),
"miliastra",
]),
],
new Date().toISOString(),
@@ -159,6 +161,7 @@ export function ReaderApp() {
return;
}
const created = createReaderStore(localStorage, opened.state);
await created.dispatch({ type: "content", events: feed.events, ...actionMeta() });
setStore(created);
setFeed({ status: "ready", feed });
})
@@ -398,15 +401,19 @@ function ReaderContent({
void dispatch({ type: "aliases", table, ...actionMeta() });
}, [feed.events, state.data, state.archive, state.seenEvents, dispatch]);
const all = useMemo(
() => readerEventRows(state, feed.events, now),
() => readerEventRows(state, feed.events, now, true),
[
state.accounts,
state.customEvents,
state.seenEvents,
state.prefs.genshinTcg,
feed.events,
Math.floor(now / 60000),
],
);
useEffect(() => {
void dispatch({ type: "content", events: feed.events, ...actionMeta() });
}, [feed.events, dispatch]);
useEffect(() => {
if (!prefs.onboarded) return;
const records = all
@@ -439,7 +446,7 @@ function ReaderContent({
.filter((a) => state.eventSeen[a.id] === undefined)
.map((a) => ({
id: a.id,
ids: feed.events.filter((e) => e.game === a.game).map((e) => e.id),
ids: feed.events.filter((e) => displayGameFor(e) === a.game).map((e) => e.id),
}));
if (entries.length)
void dispatch({ type: "seenBatch", entries, ...actionMeta() });
@@ -547,6 +554,7 @@ function ReaderContent({
const outstanding = all.filter(
(r) =>
!r.clock.ended &&
(!passPlacementId(r.event) || state.accounts[r.accountId]?.routines[passPlacementId(r.event)!]?.checklist) &&
!state.archive[pairKey(r.accountId, r.event.id)] &&
progress(r).status !== "done" &&
!state.data[r.accountId]?.ignored[r.event.id],
@@ -568,6 +576,7 @@ function ReaderContent({
const visible = all.filter(
(r) =>
(focus === null || r.event.game === focus) &&
!passPlacementId(r.event) &&
!r.clock.ended &&
(!r.clock.upcoming || prefs.showUpcoming) &&
(!state.archive[pairKey(r.accountId, r.event.id)] ||
@@ -1059,7 +1068,7 @@ function ReaderContent({
) : settingsPage === "sources" ? (
<DataSourcesPage
sources={feed.sources.filter((s) =>
trackedGames(state).has(s.game),
trackedGames(state).has(s.game) || (s.game === "genshin" && trackedGames(state).has("miliastra")),
)}
now={now}
timezone={zone}
@@ -1369,7 +1378,7 @@ function ReaderDetail({
});
const own = recordFor(ownProps.events, row.event.id);
const stale = freshness(
sources.filter((s) => s.game === row.event.game),
sources.filter((s) => s.game === sourceGameFor(row.event.game)),
now,
).stale;
return (
@@ -17,6 +17,7 @@ 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";
import { RoutineProgress } from "./RoutineProgress.tsx";
export function RoutineControl({
row,
onAction,
@@ -36,6 +37,7 @@ export function RoutineControl({
useEffect(() => {
if (!editing.current) setAmount(String(row.status.count));
}, [row.status.count, row.status.periodStart]);
if (row.event) return <div className="routine-control"><strong>{row.title}</strong><RoutineProgress row={row} onAction={onAction} now={now} /></div>;
return (
<div className="routine-control">
{burst > 0 && <Fireworks key={burst} />}
@@ -30,6 +30,8 @@ import { resolveReaderClock } from "../../../shared/readerClock.ts";
import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts";
import { VERSIONS, versionWindow } from "../../../shared/versions.ts";
import { cadenceOf } from "../../../shared/period.ts";
import { sourceGameFor } from "../../../shared/classification.ts";
import { passFamiliesForGame } from "../../state/reader/contentRouting.ts";
export type ActionHandler = (action: ReaderAction) => Promise<SaveResult>;
export const GAME_TABS = ["setup", "codes", "about"] as const;
export type GameTab = (typeof GAME_TABS)[number];
@@ -89,6 +91,7 @@ export function GameSettings({
const [addingServer, setAddingServer] = useState(false);
const [server, setServer] = useState<Region>("asia");
const [message, setMessage] = useState("");
const [savingTcg, setSavingTcg] = useState(false);
const accounts = Object.values(state.accounts).filter(
(a) => a.game === selected && !a.retiredAt,
);
@@ -214,14 +217,33 @@ export function GameSettings({
</div>
)}
</div>
{selected === "genshin" && (
<section className="grp">
<h3>Event tracking</h3>
<label className="trow">
<span>Track TCG events</span>
<input type="checkbox" checked={state.prefs.genshinTcg !== false} disabled={savingTcg}
onChange={async (e) => {
const value = e.target.checked;
setSavingTcg(true);
const result = await onAction({ type: "prefs", patch: { genshinTcg: value }, ...actionMeta() });
setSavingTcg(false);
setMessage(result.ok ? "" : "TCG tracking could not be saved. Your previous setting is kept.");
}} />
</label>
<p className="help">Show Genius Invokation TCG events in your lists and timeline.</p>
<p className="help">Miliastra Wonderland has its own game settings. Enable it in Your games to track its activities separately.</p>
{message && <p role="status">{message}</p>}
</section>
)}
<section className="grp">
<h3>Data sources</h3>
{sources.filter((s) => s.game === selected).length === 0 ? (
{sources.filter((s) => s.game === sourceGameFor(selected)).length === 0 ? (
<p className="muted-copy">No built-in source for this game.</p>
) : (
<>
{sources
.filter((s) => s.game === selected)
.filter((s) => s.game === sourceGameFor(selected))
.map((src) => (
<p className="game-source" key={src.sourceId}>
<a href={src.url} target="_blank" rel="noreferrer">
@@ -241,7 +263,7 @@ export function GameSettings({
{[
...new Set(
sources
.filter((s) => s.game === selected)
.filter((s) => s.game === sourceGameFor(selected))
.map((src) => hostOf(src.url)),
),
].join(" and ")}{" "}
@@ -626,9 +648,9 @@ function AccountSetup({
const [draft, setDraft] = useState<TrackingIntent>(a.intent);
const builtins = routinesFor(a.game);
const availability = {
dailies: !a.game.startsWith("mygame:"),
dailies: !a.game.startsWith("mygame:") && a.game !== "miliastra",
weeklyMonthly: builtins.some((r) => cadenceOf(r.period) !== "cycle"),
cycles: builtins.some((r) => cadenceOf(r.period) === "cycle"),
cycles: builtins.some((r) => cadenceOf(r.period) === "cycle") || passFamiliesForGame(a.game).length > 0,
};
/**
* Saved on each click. Only the kind that changed touches its routines, so
@@ -648,6 +670,7 @@ function AccountSetup({
placements[id] = { followed, checklist: followed, timeline: followed };
};
if (changed.includes("dailies")) place(`dailies:${a.game}`, next.dailies);
if (changed.includes("cycles")) for (const id of passFamiliesForGame(a.game)) place(id, next.cycles);
for (const r of builtins) {
const kind = cadenceOf(r.period) === "cycle" ? "cycles" : "weeklyMonthly";
if (changed.includes(kind)) place(r.id, next[kind]);
@@ -694,7 +717,7 @@ function AccountSetup({
"Routines on a game-specific schedule, such as a 14-day endgame mode",
ids: builtins
.filter((r) => cadenceOf(r.period) === "cycle")
.map((r) => r.id),
.map((r) => r.id).concat(passFamiliesForGame(a.game)),
},
] as const
).map((k) => {
@@ -106,6 +106,14 @@ export function PinnedRoutine({
});
};
const toggle = () => {
if (row.event) {
acted.current = true;
finishesGroup.current = finishes;
void onAction(met
? { type: "restore", accountId: row.accountId, eventId: row.id, ...actionMeta() }
: { type: "outcome", outcome: "done", accountId: row.accountId, eventId: row.id, ...actionMeta() });
return;
}
if (amount) {
if (!met) {
setBefore(count);
@@ -131,7 +139,7 @@ export function PinnedRoutine({
const span = routinePeriod(row, now);
const resets = span.estimated
? estimatedReset(row, now)
: `resets in ${formatRemaining(span.end - now)}`;
: `${row.event ? "ends" : "resets"} in ${formatRemaining(span.end - now)}`;
const progress = amount
? `${count.toLocaleString()} / ${required.toLocaleString()}${unit ? ` ${unit}` : ""}`
: required > 1
@@ -209,9 +217,9 @@ export function PinnedRoutine({
? `${row.title}, done ${lower(period)}. Undo`
: amount
? `Mark ${row.title} complete`
: `${row.title}, not done ${lower(period)}. Tick today`
: row.event ? `Mark ${row.title} complete` : `${row.title}, not done ${lower(period)}. Tick today`
}
title={met ? "Undo" : amount ? "Mark complete" : "Tick today"}
title={met ? "Undo" : amount || row.event ? "Mark complete" : "Tick today"}
onClick={toggle}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
@@ -232,7 +232,7 @@ function Library({
const a = writes.get(row.accountId) ?? state.accounts[row.accountId]!;
writes.set(row.accountId, {
...a,
routines: { ...a.routines, [row.id]: follow(row, on) },
routines: { ...a.routines, [row.placementId ?? row.id]: follow(row, on) },
});
}
return write(writes);
@@ -302,7 +302,7 @@ function Library({
a.id,
routineRows(state, a.id, now).filter(
(r) =>
r.id.startsWith("routine:") || r.id.startsWith("dailies:"),
r.id.startsWith("routine:") || r.id.startsWith("dailies:") || r.event !== undefined,
),
]),
);
@@ -313,7 +313,7 @@ function Library({
.map((r) => r.id)
.filter(
(id) =>
id.startsWith("dailies:") || builtins.some((b) => b.id === id),
id.startsWith("dailies:") || builtins.some((b) => b.id === id) || (byAccount.get(mine[0]!.id) ?? []).some(r => r.id === id && r.event),
);
const name = meta(game).name;
const shown = ids.filter((id) => {
@@ -383,7 +383,7 @@ function Library({
def?.period.unit === "version"
? versionWindow(def.game, now)
: undefined;
const line = def
const line = first.event ? ["Battle pass", "Uses its sourced window", "Mark complete when finished"] : def
? [
def.period.unit === "version"
? version
@@ -20,6 +20,13 @@ export function RoutineProgress({
now: number;
}) {
const [editing, setEditing] = useState(false);
if (row.event) return <button type="button" className="tick" aria-pressed={row.status.met}
aria-label={`${row.title}, ${row.status.met ? "done. Undo" : "mark complete"}`}
onClick={() => void onAction(row.status.met
? { type: "restore", accountId: row.accountId, eventId: row.id, ...actionMeta() }
: { type: "outcome", outcome: "done", accountId: row.accountId, eventId: row.id, ...actionMeta() })}>
<span className="box" aria-hidden="true"><Check /></span>
</button>;
if (row.status.measure !== "amount") {
const today = readerDayKey(now, row.clock),
done = row.days.includes(today),
@@ -39,7 +39,7 @@ export function placementAccounts(
...account,
routines: {
...account.routines,
[row.id]: { ...row.placement, [key]: value },
[row.placementId ?? row.id]: { ...row.placement, [key]: value },
},
});
}
@@ -309,7 +309,7 @@ export function RoutinesPage({
...a,
routines: {
...a.routines,
[row.id]: { ...row.placement, [key]: value },
[row.placementId ?? row.id]: { ...row.placement, [key]: value },
},
},
...actionMeta(),
@@ -362,7 +362,7 @@ export function RoutinesPage({
</div>
</div>
<div className="band-reset">
<small>Next reset in</small>
<small>Next deadline in</small>
<strong>
{(() => {
// An estimated end is not a reset (spec 2026-10-22).
@@ -441,7 +441,7 @@ export function RoutinesPage({
? "Weekly"
: row.cadence === "month"
? "Monthly"
: "Game cycle"}
: row.event ? "Battle pass" : "Game cycle"}
</small>
)}
</td>
@@ -1,3 +1,4 @@
import { passFamiliesForGame } from "../../state/reader/contentRouting.ts";
import { useState, type CSSProperties, type ReactNode } from "react";
import { TimezoneSelect } from "./TimezoneSelect.tsx";
import { ViewCards } from "./ViewCards.tsx";
@@ -84,8 +85,8 @@ export function SetupWizard({
k === "events"
? 0
: k === "dailies"
? 1
: routinesFor(g).filter(
? (g === "miliastra" ? 0 : 1)
: (k === "cycles" ? passFamiliesForGame(g).length : 0) + routinesFor(g).filter(
(r) =>
offered(r, standard) &&
(cadenceOf(r.period) === "cycle") === (k === "cycles"),
@@ -54,6 +54,7 @@ export function routinePeriod(
row: RoutineRow,
now: number,
): { start: number; end: number; estimated: boolean } {
if (row.window) return { ...row.window, estimated: false };
const key = readerDayKey(now, row.clock),
task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id);
if (task?.period.unit === "version") {
@@ -301,8 +302,8 @@ export function TimelinePage({
{period.estimated
? estimatedReset(r, now)
: showEnd
? `resets ${formatInZone(period.end, true, zone)}`
: `${formatRemaining(period.end - now)} to reset`}
? `${r.event ? "ends" : "resets"} ${formatInZone(period.end, true, zone)}`
: `${formatRemaining(period.end - now)} to ${r.event ? "end" : "reset"}`}
</span>
</button>
{!period.estimated && (
@@ -410,8 +411,8 @@ export function TimelinePage({
label: period.estimated
? estimatedReset(r, now)
: showEnd
? `resets ${day(period.end)}`
: `${formatRemaining(period.end - now)} to reset`,
? `${r.event ? "ends" : "resets"} ${day(period.end)}`
: `${formatRemaining(period.end - now)} to ${r.event ? "end" : "reset"}`,
onOpen: () => setRoutine(r),
};
})}
@@ -571,7 +572,7 @@ function RoutineDetail({
estimatedReset(row, now).replace(/^r/, "R")
) : (
<>
Resets {formatInZone(period.end, true, zone)} · in{" "}
{row.event ? "Ends" : "Resets"} {formatInZone(period.end, true, zone)} · in{" "}
{formatRemaining(period.end - now)}
</>
)}
+2
View File
@@ -12,6 +12,8 @@ export function createAccount(
return {
id,
game,
passTrackingMigrated: true,
legacyPassVisibility: false,
region,
intent: {
events: true,
+189
View File
@@ -0,0 +1,189 @@
import { classifiedEvent, displayGameFor, passPlacementId } from "../../../shared/classification.ts";
import type { DisplayEvent } from "../../../shared/custom.ts";
import { DisplayEventSchema, emptyAccountData, pairKey, type ReaderState } from "./schema.ts";
import { recordOf } from "./actions.ts";
import type { ReaderAction } from "./reducer.ts";
/** Identity hints from the audit; used only for old marks with no snapshot. */
export function legacyMiliastraId(id: string): boolean {
return /^genshin:(?:starlit-gala(?:-wonderland-selects)?|silvery-moonlight-chronicle|phantasmagoric-chronicle|starlight-voyage-[a-z-]+|raiment-collection-[a-z-]+):\d{4}-\d{2}-\d{2}$/.test(id);
}
export function contentEvents(state: ReaderState): DisplayEvent[] {
const out = new Map<string, DisplayEvent>();
for (const raw of [
...Object.values(state.archive).map(r => r.event),
...Object.values(state.seenEvents).map(r => r.event),
...Object.values(recordOf(state.contentEvents)),
]) {
const p = DisplayEventSchema.safeParse(raw);
if (p.success) out.set(p.data.id, p.data);
}
return [...out.values()];
}
function routedId(state: ReaderState, account: string, item: string): string {
const to = recordOf(state.contentRoutes)[account];
if (typeof to !== "string" || !state.accounts[to]) return account;
return legacyMiliastraId(item) || contentEvents(state).some(e => e.id === item && displayGameFor(e) === "miliastra") ? to : account;
}
/** Late operations from a pre-upgrade tab must use destination tombstones. */
export function routeContentAction(state: ReaderState, action: ReaderAction): ReaderAction {
if (!("accountId" in action)) return action;
const item = "itemId" in action ? action.itemId : "eventId" in action ? action.eventId : null;
if (!item) return action;
const accountId = routedId(state, action.accountId, item);
if (accountId === action.accountId) return action;
if (action.type === "archive" && action.record)
return { ...action, accountId, record: { ...action.record, accountId, event: classifiedEvent(action.record.event) } };
return { ...action, accountId };
}
const families: Record<string, string[]> = {
genshin: ["battle-pass"], miliastra: ["miliastra-pass"], hsr: ["nameless-honor"], zzz: ["city-fund"], nikki: ["mira-journey"],
};
export const passFamiliesForGame = (game: string): string[] => (families[game] ?? []).map(f => `feed-cycle:${game}:${f}`);
/**
* Normalize under the reader lock, before validation/save. The wire event keeps
* its source game; only saved snapshots and client rows use the logical lane.
* Every identity and day key survives. Origin timestamps remain as tombstones
* so an old tab or an old backup cannot recreate a cleared destination value.
*/
export function routeReaderContent(state: ReaderState): ReaderState {
const next = structuredClone(state);
const routes = { ...recordOf(next.contentRoutes) };
const events = contentEvents(next);
const miliastraIds = new Set(events.filter(e => displayGameFor(e) === "miliastra").map(e => e.id));
const isMoved = (id: string) => miliastraIds.has(id) || legacyMiliastraId(id);
const times = { ...recordOf(next.fieldTimes) } as Record<string, string>;
const stamp = (v: unknown) => String(recordOf(v).at ?? recordOf(v).updatedAt ?? "");
const fieldTime = (a: string, kind: string, id: string, field?: string) => times[JSON.stringify(field === undefined ? [a, kind, id] : [a, kind, id, field])] ?? "";
for (const origin of Object.values(next.accounts).filter(a => a.game === "genshin")) {
const src = next.data[origin.id];
if (!src) continue;
const ids = new Set([
...Object.keys(src.progress), ...Object.keys(src.ignored),
...Object.values(src.daily).flatMap(Object.keys),
...Object.values(src.amounts).flatMap(Object.keys),
...(next.eventSeen[origin.id] ?? []),
...Object.values(next.seenEvents).filter(r => r.accountId === origin.id).map(r => r.event.id),
...Object.values(next.archive).filter(r => r.accountId === origin.id).map(r => r.event.id),
].filter(isMoved));
// Inventory supplies a destination even on a device with no prior marks.
if (miliastraIds.size && origin.intent.events) for (const id of miliastraIds) ids.add(id);
if (!ids.size && typeof routes[origin.id] !== "string") continue;
const placeholder = Object.values(next.accounts).find(a => a.game === "miliastra" && a.contentPlaceholder === true && a.region === origin.region && !a.contentOrigin && Object.values(next.data[a.id]?.progress ?? {}).length === 0);
const targetId = typeof routes[origin.id] === "string" ? routes[origin.id] as string : placeholder?.id ?? `account:miliastra-${origin.id.slice("account:".length)}`;
const existing = next.accounts[targetId];
// A deterministic target may never overwrite an unrelated imported account.
if (existing && existing !== placeholder && existing.contentOrigin !== origin.id) throw new Error("content account identity collision");
routes[origin.id] = targetId;
if (!existing || existing === placeholder) {
next.accounts[targetId] = { ...origin, id: targetId, game: "miliastra", contentOrigin: origin.id, contentPlaceholder: false, passTrackingMigrated: false, legacyPassVisibility: origin.legacyPassVisibility ?? origin.intent.events, routines: {}, intent: { events: origin.intent.events, dailies: false, weeklyMonthly: false, cycles: origin.intent.cycles } };
next.data[targetId] = emptyAccountData();
}
const target = next.accounts[targetId]!;
// Keep newly encountered historical revisions; never replace a destination's
// active clock or settings after its independent creation.
for (const c of origin.clocks) if (!target.clocks.some(t => t.id === c.id)) target.clocks.unshift({ ...c, historicalOnly: true });
const dst = next.data[targetId]!;
for (const id of ids) {
if (Object.hasOwn(src.progress, id)) {
const a = recordOf(src.progress[id]), b = recordOf(dst.progress[id]), merged = { ...b };
for (const [field, value] of Object.entries(a)) {
const sourceTime = fieldTime(origin.id, "progress", id, field) || stamp(a);
const destTime = fieldTime(targetId, "progress", id, field) || stamp(b);
if (sourceTime > destTime || (!Object.hasOwn(b, field) && !destTime)) merged[field] = value;
}
dst.progress[id] = merged;
delete src.progress[id];
}
if (Object.hasOwn(src.ignored, id)) {
const st = fieldTime(origin.id, "ignore", id) || stamp(src.ignored[id]);
const dt = fieldTime(targetId, "ignore", id) || stamp(dst.ignored[id]);
if (st > dt || !dt) dst.ignored[id] = src.ignored[id];
delete src.ignored[id];
}
for (const kind of ["daily", "amounts"] as const) {
for (const [revision, items] of Object.entries(src[kind])) {
if (!Object.hasOwn(items, id)) continue;
const values = dst[kind][revision] ??= {};
const a = recordOf(items[id]), b = recordOf(values[id]);
if (kind === "daily") {
const ad = Array.isArray(a.days) ? a.days as string[] : [];
const bd = Array.isArray(b.days) ? b.days as string[] : [];
const days = new Set(bd);
for (const day of ad) {
const path = (account: string) => JSON.stringify([account, revision, id, day]);
const st = times[path(origin.id)] || stamp(a), dt = times[path(targetId)] || stamp(b);
if (st > dt || !dt) days.add(day);
}
values[id] = { ...a, ...b, days: [...days].sort(), at: stamp(a) > stamp(b) ? stamp(a) : stamp(b) };
} else {
const merged = { ...b };
for (const [period, value] of Object.entries(a)) {
const path = (account: string) => JSON.stringify([account, revision, id, period]);
const st = times[path(origin.id)] || stamp(value), dt = times[path(targetId)] || stamp(b[period]);
if (st > dt || !dt) merged[period] = value;
}
values[id] = merged;
}
delete items[id];
}
}
for (const kind of ["archive", "seenEvents"] as const) {
const from = pairKey(origin.id, id), to = pairKey(targetId, id), r = next[kind][from];
if (!r) continue;
const old = next[kind][to];
const rtime = String("outcomeAt" in r ? r.outcomeAt : r.lastSeenAt);
const oldTime = old ? String("outcomeAt" in old ? old.outcomeAt : old.lastSeenAt) : "";
const deletion = kind === "archive" ? times[JSON.stringify(["archive", targetId, id])] ?? "" : "";
if (rtime > oldTime && rtime >= deletion) {
const moved = { ...r, accountId: targetId, event: classifiedEvent(r.event) };
if (kind === "archive") next.archive[to] = moved as ReaderState["archive"][string];
else next.seenEvents[to] = moved as ReaderState["seenEvents"][string];
}
delete next[kind][from];
}
}
const movedSeen = (next.eventSeen[origin.id] ?? []).filter(isMoved);
if (movedSeen.length) {
next.eventSeen[targetId] = [...new Set([...(next.eventSeen[targetId] ?? []), ...movedSeen])];
next.eventSeen[origin.id] = (next.eventSeen[origin.id] ?? []).filter(id => !isMoved(id));
}
for (const [key, value] of Object.entries(times)) {
let path: unknown;
try { path = JSON.parse(key); } catch { continue; }
if (!Array.isArray(path)) continue;
if (path[0] === origin.id && path.some(v => typeof v === "string" && isMoved(v))) path[0] = targetId;
else if (path[0] === "archive" && path[1] === origin.id && typeof path[2] === "string" && isMoved(path[2])) path[1] = targetId;
else continue;
const to = JSON.stringify(path);
if (!times[to] || times[to]! < value) times[to] = value;
}
}
if (Object.keys(routes).length) {
next.contentRoutes = routes;
const known = Array.isArray(next.prefs.knownGames) ? next.prefs.knownGames as string[] : Object.values(state.accounts).map(a => a.game);
if (!known.includes("miliastra")) {
next.prefs.knownGames = [...known, "miliastra"];
next.prefs.hiddenGames = [...new Set([...(Array.isArray(next.prefs.hiddenGames) ? next.prefs.hiddenGames : []), "miliastra"])];
}
}
for (const account of Object.values(next.accounts)) {
if (account.passTrackingMigrated === true) continue;
const legacy = account.legacyPassVisibility === true || (account.legacyPassVisibility === undefined && account.intent.events);
for (const key of passFamiliesForGame(account.game)) account.routines[key] ??= { followed: legacy, checklist: legacy, timeline: legacy };
account.legacyPassVisibility = legacy;
account.passTrackingMigrated = true;
}
// Preserve source identities here. This cache also exposes passes when Events
// is off, so they need not masquerade as seen/unfinished ordinary events.
for (const event of events) {
const key = passPlacementId(event);
if (!key) continue;
for (const a of Object.values(next.accounts).filter(a => a.game === displayGameFor(event))) a.routines[key] ??= { followed: a.intent.cycles, checklist: a.intent.cycles, timeline: a.intent.cycles };
}
if (Object.keys(times).length) next.fieldTimes = times;
return JSON.stringify(next) === JSON.stringify(state) ? state : next;
}
+7 -2
View File
@@ -13,6 +13,7 @@ import {
import type { RowEvent } from "../../components/EventRow.tsx";
import type { Region } from "../../../shared/schema.ts";
import type { ReaderState } from "./schema.ts";
import { classifiedEvent, passPlacementId, tracksEventCategory } from "../../../shared/classification.ts";
export type ReaderEventRow = RowEvent & {
accountId: string;
region: Region;
@@ -22,6 +23,7 @@ export function readerEventRows(
state: ReaderState,
events: readonly DisplayEvent[],
now: number,
includeCycles = false,
): ReaderEventRow[] {
const source = new Map(
events.filter((e) => e.status === "published").map((e) => [e.id, e]),
@@ -39,7 +41,6 @@ export function readerEventRows(
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))
)
@@ -53,8 +54,12 @@ export function readerEventRows(
state.customEvents[ruleIdOf(seen.event.id)])
)
scoped.set(seen.event.id, seen.event);
for (const event of scoped.values()) {
for (const raw of scoped.values()) {
const event = classifiedEvent(raw);
if (event.game !== a.game) continue;
if (!tracksEventCategory(event, state.prefs)) continue;
const family = passPlacementId(event);
if (family ? !includeCycles || !a.routines[family]?.followed : !a.intent.events) continue;
const clock = readerClockFor(event, a, now);
out.push({
event,
+92 -9
View File
@@ -1,6 +1,8 @@
import type { ReaderState } from "./schema.ts";
import { pairKey } from "./schema.ts";
import { recordOf } from "./actions.ts";
import { contentEvents, legacyMiliastraId, routeReaderContent } from "./contentRouting.ts";
import { displayGameFor } from "../../../shared/classification.ts";
const stamp = (v: unknown) => {
const r = recordOf(v);
return typeof r.updatedAt === "string"
@@ -117,15 +119,32 @@ export function mergeReaderData(
next.importedExtensions.map((value) => [JSON.stringify(value), value]),
).values(),
];
for (const account of Object.values(incoming.accounts)) {
const mine = Object.values(next.accounts).find(
const accountMap = new Map<string, string>();
const usedTargets = new Set<string>();
const movedIds = new Set(contentEvents(incoming).filter(e => displayGameFor(e) === "miliastra").map(e => e.id));
const localTimes = recordOf(current.fieldTimes), remoteTimes = recordOf(incoming.fieldTimes);
const mergedTimes = { ...localTimes };
for (const account of Object.values(incoming.accounts).sort((a, b) => Number(a.game === "miliastra") - Number(b.game === "miliastra"))) {
const origin = typeof account.contentOrigin === "string" ? accountMap.get(account.contentOrigin) ?? account.contentOrigin : undefined;
const hasMovedData = account.game === "genshin" && (typeof recordOf(incoming.contentRoutes)[account.id] === "string" || [
...Object.keys(incoming.data[account.id]?.progress ?? {}),
...Object.keys(incoming.data[account.id]?.ignored ?? {}),
...Object.values(incoming.data[account.id]?.daily ?? {}).flatMap(Object.keys),
...Object.values(incoming.data[account.id]?.amounts ?? {}).flatMap(Object.keys),
...Object.values(incoming.seenEvents).filter(r => r.accountId === account.id).map(r => r.event.id),
...Object.values(incoming.archive).filter(r => r.accountId === account.id).map(r => r.event.id),
].some(id => legacyMiliastraId(id) || movedIds.has(id)));
const exact = next.accounts[account.id];
const mine = (exact?.game === account.game ? exact : undefined) ?? Object.values(current.accounts).find(
(a) =>
a.game === account.game && a.region === account.region && !a.retiredAt,
!hasMovedData && a.game === account.game && a.region === account.region && !a.retiredAt && !usedTargets.has(a.id) && (account.game !== "miliastra" || a.contentOrigin === origin),
);
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.accounts[id] = mine ?? { ...account, id, ...(origin ? { contentOrigin: origin } : {}) };
accountMap.set(account.id, id);
usedTargets.add(id);
next.eventSeen[id] = [
...new Set([
...(next.eventSeen[id] ?? []),
@@ -176,7 +195,16 @@ export function mergeReaderData(
...(Array.isArray(b.days) ? b.days : []),
]),
]
.filter((d): d is string => typeof d === "string")
.filter((d): d is string => {
if (typeof d !== "string") return false;
const local = recordOf(current.fieldTimes)[JSON.stringify([id, target, item, d])];
const imported = recordOf(incoming.fieldTimes)[JSON.stringify([account.id, revision, item, d])];
// A local deletion is authoritative over an older backup. Explicit
// newer imported operations can still restore that day.
const remoteTime = String(imported ?? ""), localTime = String(local ?? "");
if (remoteTime > localTime && remoteTime >= stamp(a)) return Array.isArray(b.days) && b.days.includes(d);
return !localTime || remoteTime > localTime || (Array.isArray(a.days) && a.days.includes(d));
})
.sort();
merged[item] = {
...a,
@@ -190,8 +218,19 @@ export function mergeReaderData(
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));
for (const [item, entry] of Object.entries(items)) {
const a = recordOf(merged[item]), b = recordOf(entry), values = { ...a };
const periods = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of Object.keys(remoteTimes)) {
try { const p = JSON.parse(key); if (p[0] === account.id && p[1] === revision && p[2] === item && typeof p[3] === "string") periods.add(p[3]); } catch { /* opaque extension */ }
}
for (const period of periods) {
const lt = String(localTimes[JSON.stringify([id, target, item, period])] ?? stamp(a[period]));
const rt = String(remoteTimes[JSON.stringify([account.id, revision, item, period])] ?? stamp(b[period]));
if (rt > lt) { if (b[period] === undefined) delete values[period]; else values[period] = b[period]; }
}
merged[item] = values;
}
amounts[target] = merged;
}
next.data[id] = {
@@ -202,11 +241,35 @@ export function mergeReaderData(
daily,
amounts,
};
const resultData = next.data[id]!;
for (const item of new Set([...Object.keys(old?.progress ?? {}), ...Object.keys(data.progress)])) {
const a = recordOf(old?.progress[item]), b = recordOf(data.progress[item]);
const value = { ...recordOf(resultData.progress[item]) };
const fields = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of Object.keys(remoteTimes)) {
try { const p = JSON.parse(key); if (p[0] === account.id && p[1] === "progress" && p[2] === item && typeof p[3] === "string") fields.add(p[3]); } catch { /* opaque extension */ }
}
for (const field of fields) {
const lt = String(localTimes[JSON.stringify([id, "progress", item, field])] ?? "");
const rt = String(remoteTimes[JSON.stringify([account.id, "progress", item, field])] ?? "");
if (lt && lt >= (rt || stamp(b))) { if (a[field] === undefined) delete value[field]; else value[field] = a[field]; }
else if (rt && rt >= stamp(a)) { if (b[field] === undefined) delete value[field]; else value[field] = b[field]; }
}
const restoreTime = String(localTimes[JSON.stringify(["archive", id, item])] ?? "");
if (restoreTime > stamp(b) && !current.archive[pairKey(id, item)] && a.status !== "done") delete value.status;
resultData.progress[item] = value;
}
for (const item of new Set([...Object.keys(old?.ignored ?? {}), ...Object.keys(data.ignored)])) {
const lt = String(localTimes[JSON.stringify([id, "ignore", item])] ?? "");
const rt = String(remoteTimes[JSON.stringify([account.id, "ignore", item])] ?? stamp(data.ignored[item]));
if (lt && lt >= rt) { if (old?.ignored[item] === undefined) delete resultData.ignored[item]; else resultData.ignored[item] = old.ignored[item]; }
else if (rt && data.ignored[item] === undefined) delete resultData.ignored[item];
}
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)
if ((!next.archive[key] || next.archive[key]!.outcomeAt < r.outcomeAt) && String(localTimes[JSON.stringify(["archive", id, r.event.id])] ?? "") <= r.outcomeAt)
next.archive[key] = {
...r,
accountId: id,
@@ -231,6 +294,26 @@ export function mergeReaderData(
...current.codeRedeemed[id],
...incoming.codeRedeemed[account.id],
};
for (const [key, time] of Object.entries(remoteTimes)) {
try {
const path = JSON.parse(key);
if (!Array.isArray(path)) continue;
if (path[0] === account.id) {
path[0] = id;
if (typeof path[1] === "string" && revisionMap.has(path[1])) path[1] = revisionMap.get(path[1]);
} else if (path[0] === "archive" && path[1] === account.id) path[1] = id;
else continue;
const target = JSON.stringify(path);
if (String(mergedTimes[target] ?? "") < String(time)) mergedTimes[target] = time;
} catch { /* Opaque field paths remain in imported extensions. */ }
}
}
return next;
const routes = { ...recordOf((next as ReaderState).contentRoutes) };
for (const [from, to] of Object.entries(recordOf(incoming.contentRoutes))) {
const origin = accountMap.get(from), target = typeof to === "string" ? accountMap.get(to) : undefined;
if (origin && target && routes[origin] === undefined) routes[origin] = target;
}
if (Object.keys(routes).length) (next as ReaderState).contentRoutes = routes;
if (Object.keys(mergedTimes).length) (next as ReaderState).fieldTimes = mergedTimes;
return routeReaderContent(next);
}
+3 -2
View File
@@ -111,7 +111,7 @@ export function migrateLegacy(
throw new Error("Duplicate minted account identity");
const revision = "initial";
const routineRuleValue = routineRule(prefs);
const chore = shownFor(choreRule(prefs), game);
const chore = game !== "miliastra" && shownFor(choreRule(prefs), game);
const routines = Object.fromEntries(
routinesFor(game).map((r) => {
const followed =
@@ -136,7 +136,7 @@ export function migrateLegacy(
timeline: shown,
};
}
if (GameId.safeParse(game).success)
if (game !== "miliastra" && GameId.safeParse(game).success)
routines[`dailies:${game}`] = {
followed: chore,
checklist: chore,
@@ -145,6 +145,7 @@ export function migrateLegacy(
state.accounts[id] = {
id,
game,
...(game === "miliastra" ? { contentPlaceholder: true } : {}),
region: prefs.region,
intent: {
events: !prefs.hiddenGames.includes(game),
+2
View File
@@ -4,6 +4,7 @@ import { routinesFor, offered } from "../../../shared/routines.ts";
import { createAccount, changeAccountRegion } from "./accounts.ts";
import type { AccountId, ReaderState, TrackingIntent } from "./schema.ts";
import { cadenceOf } from "../../../shared/period.ts";
import { passFamiliesForGame } from "./contentRouting.ts";
export interface SetupDraft {
region: Region;
interface: "relaxed" | "structured";
@@ -51,6 +52,7 @@ export function setupReader(
routines[r.id] = { followed, checklist: followed, timeline: followed };
}
accounts[a.id] = { ...a, intent, routines };
for (const id of passFamiliesForGame(game)) routines[id] = { followed: intent.cycles, checklist: intent.cycles, timeline: intent.cycles };
}
for (const [id, a] of Object.entries(accounts))
if (!draft.games.includes(a.game))
+15 -1
View File
@@ -1,4 +1,8 @@
import { applyReaderAliases } from "./aliases.ts";
import { contentEvents, routeContentAction, routeReaderContent } from "./contentRouting.ts";
import type { DisplayEvent } from "../../../shared/custom.ts";
import { classificationFor, classifiedEvent } from "../../../shared/classification.ts";
import { resolveReaderClock } from "../../../shared/readerClock.ts";
import { archiveOutcome, restoreArchive, reconcileArchive } from "./archive.ts";
import {
type Account,
@@ -9,6 +13,7 @@ import {
pairKey,
} from "./schema.ts";
export type ReaderAction = { at: string; opId: string } & (
| { type: "content"; events: DisplayEvent[] }
| {
type: "outcome";
accountId: string;
@@ -103,6 +108,8 @@ export function reduceReader(
state: ReaderState,
action: ReaderAction,
): ReaderState {
state = routeReaderContent(state);
action = routeContentAction(state, action);
const operations = Array.isArray(state.operations)
? (state.operations as string[])
: [];
@@ -120,8 +127,14 @@ export function reduceReader(
...Object.fromEntries(fields.map((k) => [k, action.at])),
},
};
if (action.type === "content") return routeReaderContent({ ...next, contentEvents: Object.fromEntries(action.events.filter(e => Object.keys(classificationFor(e)).length > 0).map(e => [e.id, e])) });
if (action.type === "aliases") return applyReaderAliases(next, action.table);
if (action.type === "outcome")
if (action.type === "outcome") {
const key = pairKey(action.accountId, action.eventId);
const a = next.accounts[action.accountId];
const event = contentEvents(next).find(e => e.id === action.eventId);
if (!next.seenEvents[key] && a && event)
next.seenEvents = { ...next.seenEvents, [key]: { accountId: a.id, event: classifiedEvent(event), firstSeenAt: action.at, lastSeenAt: action.at, clockRevision: resolveReaderClock(a, Date.parse(action.at)).revision } };
return archiveOutcome(
next,
action.accountId,
@@ -129,6 +142,7 @@ export function reduceReader(
action.outcome,
action.at,
);
}
if (action.type === "restore")
return restoreArchive(next, action.accountId, action.eventId);
if (action.type === "reconcile")
+26 -1
View File
@@ -16,6 +16,9 @@ import {
import { resetHourFor, serverOffsetUtc } from "../../../shared/time.ts";
import type { GoalStatus } from "../../../shared/goal.ts";
import type { Account, ReaderState, RoutinePlacement } from "./schema.ts";
import { classifiedEvent, displayGameFor, passPlacementId, tracksEventCategory } from "../../../shared/classification.ts";
import { contentEvents } from "./contentRouting.ts";
import type { DisplayEvent } from "../../../shared/custom.ts";
export interface RoutineRow {
id: string;
accountId: string;
@@ -28,6 +31,10 @@ export interface RoutineRow {
days: string[];
source: string | null;
task: Task | null;
/** A source-dated pass; progress is still keyed by this window's event ID. */
event?: DisplayEvent;
placementId?: string;
window?: { start: number; end: number };
}
export function placementFor(account: Account, id: string): RoutinePlacement {
return (
@@ -57,7 +64,7 @@ export function routineRows(
: [];
};
const rows: RoutineRow[] = [];
if (!isCustomGameId(account.game)) {
if (!isCustomGameId(account.game) && account.game !== "miliastra") {
const id = `dailies:${account.game}`,
days = daysFor(id),
count = days.includes(today) ? 1 : 0;
@@ -146,6 +153,8 @@ export function routineRows(
goal = readGoal(p.goal),
window = readerClockFor(event, account, now);
if (
!tracksEventCategory(event, state.prefs) ||
passPlacementId(event) !== null ||
!window.live ||
state.archive[JSON.stringify([accountId, event.id])] ||
p.status === "done" ||
@@ -207,5 +216,21 @@ export function routineRows(
task: null,
});
}
for (const raw of contentEvents(state)) {
if (displayGameFor(raw) !== account.game) continue;
const event = classifiedEvent(raw), placementId = passPlacementId(event);
if (!placementId) continue;
const window = readerClockFor(event, account, now);
if (window.ended || window.endsMs === null || (window.upcoming && !state.prefs.showUpcoming)) continue;
const p = recordOf(data.progress[event.id]);
const archived = state.archive[JSON.stringify([accountId, event.id])];
const met = p.status === "done" || data.ignored[event.id] !== undefined || archived?.outcome === "done" || archived?.outcome === "ignored";
rows.push({
id: event.id, accountId, game: account.game, title: event.title,
cadence: "cycle", status: { scope: "event", measure: "days", periodStart: event.startsAt.slice(0, 10), required: 1, count: met ? 1 : 0, met, needed: met ? 0 : 1, open: met ? 0 : 1, shortfall: false },
placement: placementFor(account, placementId), placementId, event,
window: { start: window.startsMs, end: window.endsMs }, clock, days: [], source: event.sourceUrl, task: null,
});
}
return rows;
}
+4 -3
View File
@@ -11,6 +11,7 @@ import {
type SaveResult,
} from "./schema.ts";
import { reduceReader, type ReaderAction } from "./reducer.ts";
import { routeReaderContent } from "./contentRouting.ts";
export interface ReaderStore {
getSnapshot(): ReaderState;
subscribe(listener: () => void): () => void;
@@ -58,10 +59,10 @@ export function initializeReader(
if (saved !== null) {
const parsed = ReaderStateSchema.safeParse(JSON.parse(saved));
if (!parsed.success) return { ok: false, reason: "invalid" };
const next = refreshAutomaticClocks(
const next = routeReaderContent(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;
@@ -102,7 +103,7 @@ export function createReaderStore(
const apply = (action: ReaderAction): SaveResult => {
try {
const current = persisted() ?? state;
const next = reduceReader(current, action);
const next = routeReaderContent(reduceReader(current, action));
if (next === current) {
state = current;
notify();
+6 -4
View File
@@ -1,5 +1,6 @@
import { freshness, type EventFeed } from "../../../shared/feed.ts";
import type { ReaderState } from "./schema.ts";
import { displayGameFor, passPlacementId, tracksEventCategory } from "../../../shared/classification.ts";
export type UpdateItem =
| { kind: "sources"; games: string[] }
| { kind: "events"; accountId: string; ids: string[] }
@@ -27,9 +28,10 @@ export function updateItems(
): UpdateItem[] {
const items: UpdateItem[] = [],
tracked = trackedGames(state);
const stale = freshness(feed.sources, now).stale.filter((s) =>
tracked.has(s.game),
);
const stale = freshness(feed.sources, now).stale.flatMap(s => [
...(tracked.has(s.game) ? [s] : []),
...(s.game === "genshin" && tracked.has("miliastra") ? [{ ...s, game: "miliastra" as const }] : []),
]);
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)) {
@@ -42,7 +44,7 @@ export function updateItems(
continue;
const ids = feed.events
.filter(
(e) => e.game === a.game && !state.eventSeen[a.id]!.includes(e.id),
(e) => displayGameFor(e) === a.game && !passPlacementId(e) && tracksEventCategory(e, state.prefs) && !state.eventSeen[a.id]!.includes(e.id),
)
.map((e) => e.id);
if (ids.length) items.push({ kind: "events", accountId: a.id, ids });