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:
Lucas Winther
2026-10-18 01:55:16 +02:00
co-authored by Claude Opus 5.5
parent ddd9673f86
commit 50c6e91b00
284 changed files with 45100 additions and 1628 deletions
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { parseGame8Codes } from "../../src/ingest/codes/game8.ts";
test("code attributes are order/quote insensitive and duplicate code rows merge", () => {
const row =
"<tr><td><input value='AbC&amp;X' class='a-clipboard__textInput' readonly></td><td>Reward &amp; more</td></tr>";
const html =
"<h3>Latest Redeem Codes</h3><table><tr><th>Redeem Codes</th><th>Rewards</th></tr>" +
row +
row +
"</table><h2>Expired codes</h2>" +
row;
const codes = parseGame8Codes(html, {
game: "genshin",
sourceId: "test",
sourceUrl: "https://example.com",
now: "2026-10-16T12:00:00.000Z",
});
expect(codes).toHaveLength(1);
expect(codes[0]?.code).toBe("AbC&X");
expect(codes[0]?.reward).toBe("Reward & more");
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { AppearancePage } from "../src/client/components/redesign/AppearancePage.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
test("both interface choices, Custom schemes and routine disclosure are available together", () => {
const s = migrateLegacy(
{},
[],
"2026-10-16T12:00:00.000Z",
() => "account:test",
);
s.prefs.scheme = "custom";
const html = render(
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
);
for (const label of [
"Relaxed",
"Structured",
"Custom",
"Dark colours",
"Light colours",
"Always expanded",
"Separate panel",
])
expect(html).toContain(label);
});
+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from "bun:test";
import {
appearanceTokens,
readableOnSurfaces,
contrastRatio,
} from "../src/client/state/appearance.ts";
test("custom colours retain contrast on both page and surface", () => {
const t = appearanceTokens(
{
scheme: "custom",
customPalette: {
dark: {
ground: "#111111",
surface: "#222222",
text: "#111111",
accent: "#222222",
},
},
},
"dark",
);
expect(contrastRatio(t.ink, t.ground)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(t.ink, t.surface)).toBeGreaterThanOrEqual(4.5);
const hue = readableOnSurfaces("#8bcfff", [t.ground, t.surface], 4.5);
expect(contrastRatio(hue, t.surface)).toBeGreaterThanOrEqual(4.5);
});
test("palette and theme are independent from interface and density", () => {
expect(
appearanceTokens(
{ scheme: "warm", interface: "structured", density: "compact" },
"light",
),
).toEqual(
appearanceTokens(
{ scheme: "warm", interface: "relaxed", density: "standard" },
"light",
),
);
});
test("Structured is the default while a saved Relaxed choice remains distinct", () => {
expect(appearanceTokens({}, "dark").ground).toBe("#0e1117");
expect(appearanceTokens({ interface: "relaxed" }, "dark").ground).toBe(
"#1a1c24",
);
});
import { urgencyTokens } from "../src/client/state/appearance.ts";
test("urgency follows the live ramp, grey to blue to yellow to red, whatever the accent", () => {
const dark = urgencyTokens(appearanceTokens({ accent: "#ff00ff" }, "dark"));
expect(dark.near).toBe("#5b8def");
expect(dark.soon).toBe("#e8b33c");
expect(dark.critical).toBe("#ff4d6a");
// Live's grey, nudged only as far as large-text contrast needs.
expect(dark.calm).not.toBe(dark.near);
const light = urgencyTokens(appearanceTokens({}, "light"));
expect(light.near).toBe("#2b62c6");
expect(light.soon).toBe("#8a6008");
expect(light.critical).toBe("#c81e3c");
expect(light.calm).not.toBe(light.near);
});
import { urgencyChoice } from "../src/client/state/appearance.ts";
test("the reader's urgency colours apply per theme; unset or invalid steps keep live's", () => {
const prefs = {
urgencyPalette: {
dark: { critical: "#a020f0", soon: "not a colour" },
light: { near: "#123456" },
},
};
const dark = urgencyChoice(prefs, "dark");
expect(dark.critical).toBe("#a020f0");
expect(dark.soon).toBe("#e8b33c");
expect(dark.near).toBe("#5b8def");
expect(urgencyChoice(prefs, "light").near).toBe("#123456");
expect(urgencyChoice({ urgencyPalette: "junk" }, "dark").critical).toBe(
"#ff4d6a",
);
// Applied, a chosen colour is kept when it already reads on the surfaces.
expect(
urgencyTokens(appearanceTokens(prefs, "dark"), prefs, "dark").critical,
).toBe("#a020f0");
});
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
import { archiveStats } from "../src/client/state/reader/archiveStats.ts";
import type { ArchiveRecord } from "../src/client/state/reader/schema.ts";
import { sampleEvent } from "./helpers/reader.ts";
const at = "2026-10-16T12:00:00.000Z";
const r = (outcome: ArchiveRecord["outcome"], id: string): ArchiveRecord => ({
accountId: "account:one",
event: { ...sampleEvent, id },
outcome,
outcomeAt: at,
resolvedEnd: Date.parse(at),
clockRevision: "initial",
progress: {},
days: ["2026-10-15", "2026-10-16"],
amounts: {},
});
test("ignored outcomes never count as failures; event check-ins are not unique play days", () => {
const result = archiveStats(
[r("done", "a"), r("ignored", "b"), r("unfinished", "c")],
Date.parse(at),
);
expect(result.overall.rate).toBe(50);
expect(result.overall.eligible).toBe(2);
expect(result.checkIns).toBe(6);
expect(result.shortfalls).toEqual([]);
});
test("empty archives have no fabricated percentages or progress", () => {
const result = archiveStats([], Date.parse(at));
expect(result.overall.rate).toBeNull();
expect(result.currentRun).toBe(0);
expect(result.bestRun).toBe(0);
});
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { ArchivePage } from "../src/client/components/redesign/ArchivePage.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { sampleEvent } from "./helpers/reader.ts";
test("per-game archive retains the description, outcome, Restore and optional history view", () => {
const at = "2026-10-16T12:00:00.000Z",
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
s.archive.a = {
accountId: "account:test",
event: { ...sampleEvent, summary: "Saved description" },
outcome: "done",
outcomeAt: at,
resolvedEnd: null,
clockRevision: "initial",
progress: {},
days: [],
amounts: {},
};
const html = render(
<ArchivePage
state={s}
now={Date.parse(at)}
onAction={async () => ({ ok: true })}
onOpen={() => {}}
/>,
);
for (const label of [
"Saved description",
"Restore",
"History timeline",
"Your recorded history",
"completed this month",
])
expect(html).toContain(label);
});
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { runRefresh } from "../scripts/refresh-sources.ts";
import { SnapshotStore } from "../src/ingest/snapshots.ts";
import { CODE_SOURCES } from "../src/ingest/codes/registry.ts";
import { sampleEvent } from "./helpers/reader.ts";
test("event and code sources share host spacing and snapshot gates", async () => {
const root = await mkdtemp("/tmp/codes-refresh-");
try {
const html = await Bun.file(
"fixtures/genshin/game8-codes-2026-10-16.html",
).text();
const slept: number[] = [];
const code = CODE_SOURCES[0]!;
const summary = await runRefresh({
adapters: [
{ ...code, id: "event-test", parse: () => [sampleEvent] },
code,
],
store: new SnapshotStore(root),
robots: {
allows: async () => ({ allowed: true, reason: "test permission" }),
},
fetchImpl: async () => new Response(html, { status: 200 }),
userAgent: "event-clock-test",
now: () => new Date("2026-10-16T12:00:00Z"),
sleep: async (n) => {
slept.push(n);
},
dryRun: false,
only: null,
force: false,
timeoutMs: 20000,
log: () => {},
rebuildFeed: null,
});
expect(summary.changed).toBe(2);
expect(slept).toEqual([2000]);
expect(summary.outcomes[1]?.eventCount).toBe(10);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("the CLI recognises the code source during a request-free dry run", () => {
const p = Bun.spawnSync(
[
"bun",
"run",
"scripts/refresh-sources.ts",
"--dry-run",
"--only",
"genshin-game8-codes",
],
{ stdout: "pipe", stderr: "pipe" },
);
expect(p.exitCode).toBe(0);
expect(new TextDecoder().decode(p.stdout)).toContain("genshin-game8-codes");
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { CodesPage } from "../src/client/components/redesign/CodesPage.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { createAccount } from "../src/client/state/reader/accounts.ts";
test("redemption controls identify each server and unknown expiry stays unknown", () => {
const at = "2026-10-16T12:00:00.000Z",
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
s.accounts["account:two"] = createAccount(
"genshin",
"asia",
at,
"account:two",
);
const html = render(
<CodesPage
game="genshin"
state={s}
onAction={async () => ({ ok: true })}
feed={{
schemaVersion: 1,
sources: [],
codes: [
{
id: "code",
game: "genshin",
code: "TEST",
reward: null,
expiresAt: null,
firstObservedAt: at,
sourceUrl: "https://example.com",
},
],
}}
/>,
);
expect(html).toContain("Redeemed · europe");
expect(html).toContain("Redeemed · asia");
expect(html).toContain("Expiry not announced");
expect(html).toContain('href="https://example.com"');
});
+56
View File
@@ -0,0 +1,56 @@
import { expect, test } from "bun:test";
import { buildCodesFeed } from "../src/ingest/codes/build.ts";
import { parseGame8Codes } from "../src/ingest/codes/game8.ts";
const at = "2026-10-16T12:00:00.000Z",
ctx = {
game: "genshin" as const,
sourceId: "genshin-game8-codes",
sourceUrl: "https://game8.co/games/Genshin-Impact/archives/304759",
now: at,
};
test("real fixture reads the ten current codes and fences off expired tables", async () => {
const html = await Bun.file(
"fixtures/genshin/game8-codes-2026-10-16.html",
).text();
const codes = parseGame8Codes(html, ctx);
expect(codes).toHaveLength(10);
expect(codes.map((c) => c.code)).toEqual([
"NEEIJB764CA3",
"6LS3F3LS5K87",
"OPERACOLLAB",
"VesnaOnPatrol",
"EPIC2026",
"Y6JYMKV6JKSL",
"Y1INABQB6DEX",
"2BJ64QRZ7RT8",
"PLDXIB886Y1S",
"GENSHINGIFT",
]);
expect(codes.every((c) => c.expiresAt === null)).toBe(true);
expect(codes[0]?.reward).toContain("Primogem");
expect(codes.some((c) => c.code === "Rekviem")).toBe(false);
});
test("first-observed time stays fixed across unchanged builds and shape changes reject", () => {
const code = {
id: '["genshin","TEST"]',
game: "genshin" as const,
code: "TEST",
reward: null,
expiresAt: null,
sourceUrl: ctx.sourceUrl,
};
const first = buildCodesFeed(null, [code], at);
expect(
buildCodesFeed(first, [code], "2026-10-20T12:00:00.000Z").codes[0]
?.firstObservedAt,
).toBe(at);
expect(() => parseGame8Codes("<h1>New design</h1>", ctx)).toThrow();
});
test("special-program codes are included while their stated expiry date is still ahead", async () => {
const html = (
await Bun.file("fixtures/genshin/game8-codes-2026-10-16.html").text()
).replace("after September 15, 2026", "after November 15, 2026");
const codes = parseGame8Codes(html, ctx);
expect(codes).toHaveLength(13);
expect(codes.some((c) => c.code === "Rekviem")).toBe(true);
});
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from "bun:test";
import { formatInZone } from "../src/client/state/displayTime.ts";
test("named timezones use their daylight-saving rules and invalid zones fall back", () => {
const winter = formatInZone(
"2026-01-01T12:00:00Z",
true,
"Europe/Copenhagen",
);
const summer = formatInZone(
"2026-07-01T12:00:00Z",
true,
"Europe/Copenhagen",
);
expect(winter).toContain("13:00");
expect(summer).toContain("14:00");
expect(() =>
formatInZone("2026-07-01T12:00:00Z", true, "invalid"),
).not.toThrow();
});
import { readerInstant } from "../src/client/state/useCustom.ts";
test("custom event entry resolves a chosen zone and rejects nonexistent daylight-saving times", () => {
expect(
readerInstant("2026-07-01", "12:00", "start", "Europe/Copenhagen"),
).toBe("2026-07-01T10:00:00.000Z");
expect(
readerInstant("2026-03-29", "02:30", "start", "Europe/Copenhagen"),
).toBeNull();
expect(
readerInstant("2026-02-30", "12:00", "start", "Europe/Copenhagen"),
).toBeNull();
});
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { GameSettings } from "../src/client/components/redesign/GameSettings.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
test("game settings explains simultaneous server tracking and credits zero-event sources", () => {
const at = "2026-10-16T12:00:00.000Z",
state = migrateLegacy({}, ["genshin"], at, () => "account:one");
const html = render(
<GameSettings
state={state}
initialGame="genshin"
sources={[
{
sourceId: "genshin-extra",
game: "genshin",
url: "https://example.com/events",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
eventCount: 0,
parsedCount: null,
statesNoEvents: false,
},
]}
onAction={async () => ({ ok: true })}
/>,
);
expect(html).toContain("track both accounts separately");
expect(html).toContain("Track another server");
expect(html).toContain("Automatic");
expect(html).toContain("Reset to default");
// Tracking choices save on click; there is no separate apply step.
expect(html).not.toContain("Apply tracking choices");
// A kind with no built-in routine is explained and still selectable.
expect(html).toContain("none built in");
expect(html).not.toMatch(/<button[^>]*disabled[^>]*data-st/);
});
import { AppearancePage } from "../src/client/components/redesign/AppearancePage.tsx";
test("Appearance orders only the games being tracked, with up and down buttons", () => {
const at = "2026-10-16T12:00:00.000Z",
state = migrateLegacy(
{},
["genshin", "hsr", "zzz"],
at,
(() => {
let n = 0;
return () => `account:${++n}`;
})(),
);
state.prefs.hiddenGames = ["zzz"];
const html = render(
<AppearancePage state={state} onAction={async () => ({ ok: true })} />,
);
expect(html).toContain("Game order");
expect(html).toContain("Move Genshin Impact down");
expect(html).toContain("Move Honkai: Star Rail up");
expect(html).not.toContain("Move Zenless Zone Zero");
expect(html).not.toContain("Sort A–Z");
});
+47
View File
@@ -0,0 +1,47 @@
import { GachaEvent } from "../../src/shared/schema.ts";
const at = "2026-10-16T12:00:00.000Z";
export const sampleEvent = GachaEvent.parse({
id: "genshin:test:2026-10-01",
game: "genshin",
title: "Test",
type: "story",
summary: null,
startsAt: "2026-10-01T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-10-20T00:00:00.000Z",
endPrecision: "day",
regionScoped: false,
regionEnds: null,
sourceUrl: "https://example.com",
sourceId: "test",
confidence: 1,
extractionMethod: "parser",
version: 1,
firstSeenAt: at,
updatedAt: at,
status: "published",
});
export class MemoryStorage implements Storage {
values = new Map<string, string>();
fail = "";
get length() {
return this.values.size;
}
clear() {
this.values.clear();
}
key(i: number) {
return [...this.values.keys()][i] ?? null;
}
getItem(k: string) {
return this.values.get(k) ?? null;
}
removeItem(k: string) {
this.values.delete(k);
}
setItem(k: string, v: string) {
if (k === this.fail) throw new DOMException("Full", "QuotaExceededError");
this.values.set(k, v);
}
}
+22 -1
View File
@@ -180,7 +180,7 @@ describe("restorePrefsValue", () => {
regionConfirmed: true,
onboarded: true,
};
expect(restorePrefsValue(exported)).toEqual(exported);
expect(restorePrefsValue(exported)).toEqual({ ...exported, interface: "structured", density: "standard" });
});
test("falls back to defaults for missing fields from older exports", () => {
@@ -225,3 +225,24 @@ describe("restorePrefsValue", () => {
expect(restored.view).toBe(defaults().view);
});
});
describe("redesign presentation preferences", () => {
test("defaults to Structured and Standard without changing the reader's main view", () => {
const p = restorePrefsValue({ view: "timeline" });
expect(p.interface).toBe("structured");
expect(p.density).toBe("standard");
expect(p.view).toBe("timeline");
});
test("keeps Structured and Compact on restore", () => {
const p = restorePrefsValue({ interface: "structured", density: "compact" });
expect(p.interface).toBe("structured");
expect(p.density).toBe("compact");
});
test("invalid choices fall back to the supported defaults", () => {
const p = restorePrefsValue({ interface: "other", density: 42 });
expect(p.interface).toBe("structured");
expect(p.density).toBe("standard");
});
});
test("a saved Relaxed interface survives preference normalization", () => { expect(restorePrefsValue({interface:"relaxed"}).interface).toBe("relaxed"); });
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test";
import {
createAccount,
changeAccountRegion,
retireAccount,
} from "../src/client/state/reader/accounts.ts";
const at = "2026-10-16T12:00:00.000Z";
test("additional accounts start events-only with fresh placements", () => {
const a = createAccount("genshin", "asia", at, "account:second");
expect(a.intent).toEqual({
events: true,
dailies: false,
weeklyMonthly: false,
cycles: false,
});
expect(a.routines).toEqual({});
});
test("changing a server preserves account identity and historical clock", () => {
const a = createAccount("genshin", "europe", at, "account:one");
const b = changeAccountRegion(
a,
"asia",
"2026-10-17T12:00:00.000Z",
"clock2",
);
expect(b.id).toBe(a.id);
expect(b.clocks).toHaveLength(2);
expect(b.clocks[0]?.region).toBe("europe");
expect(retireAccount(b, at).clocks).toEqual(b.clocks);
});
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from "bun:test";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { applyReaderAliases } from "../src/client/state/reader/aliases.ts";
const at = "2026-10-16T12:00:00.000Z";
test("retired and merged event ids carry progress and ticks in every account/revision", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.data["account:one"]!.progress.old = { status: "done", at };
s.data["account:one"]!.daily.initial = { old: { days: ["2026-10-16"], at } };
const next = applyReaderAliases(s, { old: "new" });
expect(next.data["account:one"]?.progress.new).toHaveProperty(
"status",
"done",
);
expect(next.data["account:one"]?.progress.old).toBeUndefined();
expect(next.data["account:one"]?.daily.initial?.new).toHaveProperty("days", [
"2026-10-16",
]);
expect(applyReaderAliases(next, { old: "new" })).toBe(next);
});
test("aliases carry amount history and durable event snapshots", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.data["account:one"]!.amounts.initial = {
old: { "2026-10-16": { value: 7, at } },
};
const next = applyReaderAliases(s, { old: "new" });
expect(next.data["account:one"]?.amounts.initial?.new).toHaveProperty(
"2026-10-16",
{ value: 7, at },
);
expect(next.data["account:one"]?.amounts.initial?.old).toBeUndefined();
});
+52
View File
@@ -0,0 +1,52 @@
import { expect, test } from "bun:test";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import {
captureSeen,
reconcileArchive,
archiveOutcome,
restoreArchive,
} from "../src/client/state/reader/archive.ts";
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
import { sampleEvent } from "./helpers/reader.ts";
import { pairKey } from "../src/client/state/reader/schema.ts";
const at = "2026-10-16T12:00:00.000Z";
test("archive saves metadata and outcome immediately; restore preserves the event snapshot", () => {
let s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s = captureSeen(s, readerEventRows(s, [sampleEvent], Date.parse(at)), at);
s = archiveOutcome(s, "account:one", sampleEvent.id, "done", at);
expect(s.archive[pairKey("account:one", sampleEvent.id)]?.event.title).toBe(
"Test",
);
expect(s.data["account:one"]?.progress[sampleEvent.id]).toHaveProperty(
"status",
"done",
);
s = restoreArchive(s, "account:one", sampleEvent.id);
expect(s.archive).toEqual({});
expect(s.seenEvents[pairKey("account:one", sampleEvent.id)]).toBeDefined();
});
test("missing source rows are not proof of expiry, and unknown end remains unresolved", () => {
let s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s = captureSeen(
s,
readerEventRows(
s,
[
sampleEvent,
{
...sampleEvent,
id: "genshin:unknown:2026-10-01",
endsAt: null,
endPrecision: "unknown",
},
],
Date.parse(at),
),
at,
);
s = reconcileArchive(s, [], Date.parse(at));
expect(s.archive).toEqual({});
s = reconcileArchive(s, [], Date.parse("2026-11-01T12:00:00Z"));
expect(Object.values(s.archive)).toHaveLength(1);
expect(Object.values(s.archive)[0]?.outcome).toBe("unfinished");
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from "bun:test";
import {
backupTier,
retainBackups,
} from "../src/client/state/reader/backups.ts";
const now = Date.parse("2026-10-17T12:00:00.000Z");
const daysAgo = (d: number) => new Date(now - d * 86_400_000).toISOString();
test("a snapshot's tier matches the window that retains it", () => {
expect(backupTier(daysAgo(0), now)).toBe("daily");
expect(backupTier(daysAgo(6), now)).toBe("daily");
expect(backupTier(daysAgo(8), now)).toBe("weekly");
expect(backupTier(daysAgo(33), now)).toBe("weekly");
expect(backupTier(daysAgo(40), now)).toBe("monthly");
// Everything retained falls in one of the three.
const kept = retainBackups(
Array.from({ length: 200 }, (_, i) => ({
id: String(i),
at: daysAgo(i),
payload: {},
})),
now,
);
const tiers = kept.map((s) => backupTier(s.at, now));
expect(tiers.filter((t) => t === "daily").length).toBe(7);
expect(tiers.filter((t) => t === "weekly").length).toBe(4);
expect(tiers.filter((t) => t === "monthly").length).toBe(6);
});
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import {
retainBackups,
type BackupSnapshot,
} from "../src/client/state/reader/backups.ts";
const now = Date.parse("2026-10-16T12:00:00Z"),
day = 86400000;
test("400 days fold to seven daily, four weekly and six monthly real snapshots", () => {
const values: BackupSnapshot[] = Array.from({ length: 400 }, (_, i) => ({
id: String(i),
at: new Date(now - i * day).toISOString(),
payload: { day: i },
}));
const kept = retainBackups(values, now);
expect(kept).toHaveLength(17);
expect(kept.slice(0, 7).map((v) => v.id)).toEqual([
"0",
"1",
"2",
"3",
"4",
"5",
"6",
]);
expect(new Set(kept.map((v) => v.id)).size).toBe(17);
});
test("latest per bucket wins, future and ancient snapshots are excluded", () => {
const values = [
{ id: "old", at: "2026-10-16T01:00:00Z", payload: {} },
{ id: "new", at: "2026-10-16T10:00:00Z", payload: {} },
{ id: "future", at: "2027-01-01T00:00:00Z", payload: {} },
{ id: "ancient", at: "2024-01-01T00:00:00Z", payload: {} },
];
expect(retainBackups(values, now).map((v) => v.id)).toEqual(["new"]);
});
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from "bun:test";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import {
appendClockRevision,
resolveReaderClock,
readerDayKey,
readerClockFor,
} from "../src/shared/readerClock.ts";
import { clockFor } from "../src/shared/time.ts";
import { dayKey } from "../src/shared/daily.ts";
import { GAMES } from "../src/shared/games.ts";
import { GachaEvent } from "../src/shared/schema.ts";
const at = "2026-10-16T12:00:00.000Z",
now = Date.parse(at);
const account = () =>
Object.values(
migrateLegacy({}, ["genshin"], at, () => "account:one").accounts,
)[0]!;
test("automatic matches every game and server's existing day-key clock", () => {
for (const game of Object.keys(GAMES))
for (const region of ["asia", "america", "europe"] as const) {
const a = Object.values(
migrateLegacy(
{ "gacha-tracker:v1:prefs": JSON.stringify({ region }) },
[game],
at,
() => "account:one",
).accounts,
)[0]!;
expect(readerDayKey(now, resolveReaderClock(a, now))).toBe(
dayKey(now, region, game),
);
}
});
test("reset override/revert retains revisions, including a backwards current day", () => {
const a = account(),
t = "2026-10-17T03:10:00.000Z";
const custom = appendClockRevision(
a,
{ hour: 5, minute: 30 },
"europe",
t,
"custom",
);
expect(
readerDayKey(Date.parse(t), resolveReaderClock(a, Date.parse(t))),
).toBe("2026-10-17");
expect(
readerDayKey(Date.parse(t), resolveReaderClock(custom, Date.parse(t))),
).toBe("2026-10-16");
expect(resolveReaderClock(custom, Date.parse(t) - 1).revision).toBe(
"initial",
);
const reverted = appendClockRevision(
custom,
null,
"europe",
"2026-10-18T12:00:00.000Z",
"reverted",
);
expect(reverted.clocks).toHaveLength(3);
expect(
resolveReaderClock(reverted, Date.parse("2026-10-18T13:00:00.000Z"))
.resetHour,
).toBe(4);
});
const event = () =>
GachaEvent.parse({
id: "genshin:test:2026-10-01",
game: "genshin",
title: "Test",
type: "story",
summary: null,
startsAt: "2026-10-01T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-10-20T00:00:00.000Z",
endPrecision: "day",
sourceUrl: "https://example.com",
regionScoped: true,
regionEnds: {},
sourceId: "test",
confidence: 1,
extractionMethod: "parser",
version: 1,
firstSeenAt: at,
updatedAt: at,
status: "published",
});
test("only reset-based boundaries move; exact regional ends remain authoritative", () => {
const a = account(),
custom = appendClockRevision(
a,
{ hour: 5, minute: 30 },
"europe",
at,
"custom",
);
const e = event();
expect(
readerClockFor(e, custom, now).endsMs! - clockFor(e, "europe", now).endsMs!,
).toBe(90 * 60000);
const exact = { ...e, regionEnds: { europe: "2026-10-20T18:00:00.000Z" } };
expect(readerClockFor(exact, custom, now).endsMs).toBe(
Date.parse(exact.regionEnds.europe),
);
expect(
readerClockFor({ ...e, endsAt: null, endPrecision: "unknown" }, custom, now)
.endsMs,
).toBeNull();
});
test("a reader-entered day-precision instant is never moved by a reset override", () => {
const a = appendClockRevision(
account(),
{ hour: 5, minute: 30 },
"europe",
at,
"custom",
);
const e = { ...event(), extractionMethod: "manual" as const };
expect(readerClockFor(e, a, now).endsMs).toBe(Date.parse(e.endsAt!));
});
import { refreshAutomaticClocks } from "../src/client/state/reader/clock.ts";
test("automatic metadata corrections create a revision instead of rewriting history", () => {
const a = account();
a.clocks[0]!.automaticBase.hour = 3;
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
state.accounts[a.id] = a;
const next = refreshAutomaticClocks(state, at);
expect(next.accounts[a.id]?.clocks).toHaveLength(2);
expect(next.accounts[a.id]?.clocks[0]?.automaticBase.hour).toBe(3);
expect(resolveReaderClock(next.accounts[a.id]!, now).resetHour).toBe(4);
});
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import { newCodes } from "../src/client/state/reader/codes.ts";
import { reduceReader } from "../src/client/state/reader/reducer.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
const at = "2026-10-16T12:00:00.000Z";
test("viewing codes clears newness independently from redemption on two accounts", () => {
const feed = {
schemaVersion: 1 as const,
sources: [],
codes: [
{
id: "code",
game: "genshin" as const,
code: "TEST",
reward: null,
expiresAt: null,
firstObservedAt: at,
sourceUrl: "https://example.com",
},
],
};
let state = migrateLegacy({}, ["genshin"], at, () => "account:one");
expect(newCodes(feed, [], "genshin")).toHaveLength(1);
state = reduceReader(state, {
type: "seen",
kind: "codeSeen",
id: "genshin",
ids: ["code"],
at,
opId: "seen",
});
expect(newCodes(feed, state.codeSeen.genshin!, "genshin")).toHaveLength(0);
expect(state.codeRedeemed).toEqual({});
state = reduceReader(state, {
type: "redeem",
accountId: "account:one",
codeId: "code",
redeemed: true,
at,
opId: "redeem",
});
expect(state.codeRedeemed["account:one"]?.code).toBe(at);
expect(state.codeRedeemed["account:two"]).toBeUndefined();
});
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { createAccount } from "../src/client/state/reader/accounts.ts";
import { sampleEvent } from "./helpers/reader.ts";
const at = "2026-10-16T12:00:00.000Z";
test("events resolve on each account's server without changing domain identities", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.accounts["account:two"] = createAccount(
"genshin",
"asia",
at,
"account:two",
);
s.data["account:two"] = { progress: {}, ignored: {}, daily: {}, amounts: {} };
const rows = readerEventRows(s, [sampleEvent], Date.parse(at));
expect(rows).toHaveLength(2);
expect(rows[0]?.event.id).toBe(sampleEvent.id);
expect(new Set(rows.map((r) => r.clock.endsMs)).size).toBe(2);
expect(new Set(rows.map((r) => r.accountId)).size).toBe(2);
});
test("missing future and unknown-end seen events remain accessible", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.seenEvents.a = {
accountId: "account:one",
event: { ...sampleEvent, endsAt: null, endPrecision: "unknown" },
firstSeenAt: at,
lastSeenAt: at,
clockRevision: "initial",
};
expect(readerEventRows(s, [], Date.parse(at))[0]?.clock.endsMs).toBeNull();
});
test("deleted reader events are not resurrected from their seen snapshots", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.seenEvents.a = {
accountId: "account:one",
event: { ...sampleEvent, id: "myevent:deleted", sourceUrl: null },
firstSeenAt: at,
lastSeenAt: at,
clockRevision: "initial",
};
expect(readerEventRows(s, [], Date.parse(at))).toHaveLength(0);
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import {
buildReaderExport,
parseReaderExport,
} from "../src/client/state/reader/export.ts";
const at = "2026-10-16T12:00:00.000Z";
test("full reader export round-trips unknown fields without including backup history", () => {
const reader = {
...migrateLegacy({}, ["genshin"], at, () => "account:one"),
future: { keep: true },
};
const value = buildReaderExport(
reader,
{
"gacha-tracker:v1:tasks": '{"unreadable":true}',
"gacha-tracker:v2:backups": "do-not-recurse",
},
at,
);
expect(parseReaderExport(value)).toEqual(reader);
expect(JSON.stringify(value)).not.toContain("do-not-recurse");
});
test("unknown versions and invalid known fields cannot be restored", () => {
const value = buildReaderExport(
migrateLegacy({}, [], at, () => "account:one"),
{},
at,
);
expect(parseReaderExport({ ...value, version: 999 })).toBeNull();
expect(
parseReaderExport({ ...value, reader: { version: 2, accounts: [] } }),
).toBeNull();
});
test("existing version-one exports remain convertible", () => {
const reader = parseReaderExport({
format: "gacha-tracker-export",
version: 1,
exportedAt: at,
prefs: { region: "asia" },
progress: { "genshin:a:2026-01-01": { status: "done", at } },
daily: {},
ignored: {},
customGames: {},
customEvents: {},
});
expect(reader?.version).toBe(2);
expect(Object.values(reader!.accounts)[0]?.region).toBe("asia");
});
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "bun:test";
import { mintAccountId } from "../src/client/state/reader/ids.ts";
import { actionMeta } from "../src/client/state/reader/actions.ts";
import { initializeReader } from "../src/client/state/reader/store.ts";
import { saveBackup, readBackups } from "../src/client/state/reader/backups.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
import { MemoryStorage } from "./helpers/reader.ts";
const at = "2026-10-16T12:00:00.000Z";
function withoutRandomUUID(run: () => void) {
const before = Object.getOwnPropertyDescriptor(crypto, "randomUUID");
Object.defineProperty(crypto, "randomUUID", {
configurable: true,
value: undefined,
});
try {
run();
} finally {
if (before) Object.defineProperty(crypto, "randomUUID", before);
else Reflect.deleteProperty(crypto, "randomUUID");
}
}
test("HTTP first-visit migration succeeds without randomUUID and retains legacy bytes", () => {
withoutRandomUUID(() => {
const storage = new MemoryStorage();
const raw = JSON.stringify({
"genshin:example:2026-10-01": { note: "Keep my progress", at },
});
storage.setItem("gacha-tracker:v1:progress", raw);
const result = initializeReader(storage, ["genshin"], at, mintAccountId);
expect(result.ok).toBe(true);
expect(storage.getItem("gacha-tracker:v1:progress")).toBe(raw);
expect(storage.getItem(READER_KEYS.reader)).not.toBeNull();
if (result.ok)
expect(
Object.values(result.state.data)[0]!.progress[
"genshin:example:2026-10-01"
],
).toHaveProperty("note", "Keep my progress");
});
});
test("HTTP edits receive distinct operation IDs without randomUUID", () => {
withoutRandomUUID(() => {
const first = actionMeta(),
second = actionMeta();
expect(first.opId).toMatch(/^[a-zA-Z0-9-]+$/);
expect(first.opId).not.toBe(second.opId);
});
});
test("HTTP local backups save normally without randomUUID", () => {
withoutRandomUUID(() => {
const storage = new MemoryStorage();
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
expect(saveBackup(storage, state, at)).toEqual({ ok: true });
expect(readBackups(storage)).toHaveLength(1);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { resolveReaderClock } from "../src/shared/readerClock.ts";
import { expect, test } from "bun:test";
import { mergeReaderData } from "../src/client/state/reader/import.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
const at = "2026-10-16T12:00:00.000Z";
test("progress import merges by game/server and preserves local preferences and unknown fields", () => {
const a = migrateLegacy({}, ["genshin"], at, () => "account:local"),
b = migrateLegacy({}, ["genshin"], at, () => "account:imported");
a.prefs.interface = "structured";
a.data["account:local"]!.progress.event = {
note: "mine",
at: "2026-10-15T12:00:00.000Z",
future: 42,
};
b.data["account:imported"]!.progress.event = { status: "done", at };
const merged = mergeReaderData(a, b);
expect(merged.prefs.interface).toBe("structured");
expect(Object.keys(merged.accounts)).toEqual(["account:local"]);
expect(merged.data["account:local"]?.progress.event).toMatchObject({
status: "done",
note: "mine",
future: 42,
});
});
test("import allocates distinct revision ids and prefers updated custom edits", () => {
const a = migrateLegacy({}, ["genshin"], at, () => "account:local");
const b = migrateLegacy({}, ["genshin"], at, () => "account:imported");
b.accounts["account:imported"]!.clocks.push(
{
...b.accounts["account:imported"]!.clocks[0]!,
id: "initial-import",
reset: { hour: 5, minute: 0 },
},
{
...b.accounts["account:imported"]!.clocks[0]!,
id: "initial",
reset: { hour: 6, minute: 0 },
},
);
a.tasks.task = { at, updatedAt: "2026-10-16T13:00:00.000Z", title: "older" };
b.tasks.task = { at, updatedAt: "2026-10-16T14:00:00.000Z", title: "newer" };
const merged = mergeReaderData(a, b);
const ids = merged.accounts["account:local"]!.clocks.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
expect(merged.tasks.task).toMatchObject({ title: "newer" });
});
test("progress import retains imported history without replacing the local reset", () => {
const a = migrateLegacy({}, ["genshin"], at, () => "account:local");
const b = migrateLegacy({}, ["genshin"], at, () => "account:imported");
b.accounts["account:imported"]!.clocks.push({
...b.accounts["account:imported"]!.clocks[0]!,
id: "custom",
effectiveAt: at,
reset: { hour: 9, minute: 0 },
});
const merged = mergeReaderData(a, b);
expect(
resolveReaderClock(merged.accounts["account:local"]!, Date.parse(at))
.resetHour,
).toBe(4);
expect(
merged.accounts["account:local"]!.clocks.some((c) => c.id === "custom"),
).toBe(true);
});
test("repeated progress imports keep clocks and opaque extension history idempotent", () => {
const a = migrateLegacy({}, ["genshin"], at, () => "account:local"),
b = migrateLegacy({}, ["genshin"], at, () => "account:remote");
b.accounts["account:remote"]!.clocks.push({
...b.accounts["account:remote"]!.clocks[0]!,
id: "override",
reset: { hour: 9, minute: 0 },
});
b.accounts["account:remote"]!.futureAccount = { opaque: 42 };
const once = mergeReaderData(a, b),
twice = mergeReaderData(once, b);
expect(twice.accounts["account:local"]!.clocks.length).toBe(
once.accounts["account:local"]!.clocks.length,
);
const third = mergeReaderData(
migrateLegacy({}, ["genshin"], at, () => "account:third"),
once,
);
expect(JSON.stringify(third.importedExtensions)).toContain('"opaque":42');
});
+91
View File
@@ -0,0 +1,91 @@
import { expect, test } from "bun:test";
import {
initializeReader,
createReaderStore,
} from "../src/client/state/reader/store.ts";
import { MemoryStorage } from "./helpers/reader.ts";
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
const at = "2026-10-16T12:00:00.000Z";
test("a returning older tab's newer edit is carried to the original migrated account", async () => {
const s = new MemoryStorage();
s.setItem(
"gacha-tracker:v1:progress",
JSON.stringify({ "genshin:a:2026-01-01": { status: "doing", at } }),
);
const first = initializeReader(s, ["genshin"], at, () => "account:original");
expect(first.ok).toBe(true);
s.setItem(
"gacha-tracker:v1:progress",
JSON.stringify({
"genshin:a:2026-01-01": {
status: "done",
note: "From old tab",
at: "2026-10-17T12:00:00.000Z",
},
}),
);
const again = initializeReader(
s,
["genshin"],
"2026-10-18T12:00:00.000Z",
() => {
throw Error("Must not remint");
},
);
expect(again.ok).toBe(true);
if (again.ok)
expect(
again.state.data["account:original"]?.progress["genshin:a:2026-01-01"],
).toHaveProperty("note", "From old tab");
});
test("a removed v2 field is not resurrected by an older legacy copy", async () => {
const s = new MemoryStorage();
s.setItem(
"gacha-tracker:v1:progress",
JSON.stringify({ "genshin:a:2026-01-01": { note: "old", at } }),
);
const first = initializeReader(s, ["genshin"], at, () => "account:original");
if (!first.ok) throw Error("init");
const store = createReaderStore(s, first.state);
await store.dispatch({
type: "progress",
accountId: "account:original",
itemId: "genshin:a:2026-01-01",
patch: { note: null },
at: "2026-10-18T12:00:00.000Z",
opId: "clear",
});
s.setItem("gacha-tracker:v1:prefs", JSON.stringify({ region: "asia" }));
initializeReader(s, ["genshin"], "2026-10-19T12:00:00.000Z", () => {
throw Error("Must not remint");
});
expect(
JSON.parse(s.getItem(READER_KEYS.reader)!).data["account:original"]
.progress["genshin:a:2026-01-01"].note,
).toBeUndefined();
});
test("old-tab removals clear migrated ticks and ignores without clearing newer v2 edits", () => {
const s = new MemoryStorage(),
id = "genshin:a:2026-01-01";
s.setItem(
"gacha-tracker:v1:daily",
JSON.stringify({ [id]: { days: ["2026-10-16"], at } }),
);
s.setItem("gacha-tracker:v1:ignored", JSON.stringify({ [id]: { at } }));
initializeReader(s, ["genshin"], at, () => "account:original");
s.setItem("gacha-tracker:v1:daily", "{}");
s.setItem("gacha-tracker:v1:ignored", "{}");
const result = initializeReader(
s,
["genshin"],
"2026-10-18T12:00:00.000Z",
() => {
throw Error("remint");
},
);
if (!result.ok) throw Error(result.reason);
expect(
result.state.data["account:original"]!.daily.initial?.[id],
).toHaveProperty("days", []);
expect(result.state.data["account:original"]!.ignored[id]).toBeUndefined();
});
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from "bun:test";
import {
deleteManualBackup,
MANUAL_BACKUP_BYTES,
MANUAL_BACKUP_LIMIT,
readBackups,
readManualBackups,
saveBackup,
saveManualBackup,
} from "../src/client/state/reader/backups.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
const memory = (): Storage => {
const m = new Map<string, string>();
return {
get length() {
return m.size;
},
key: (i) => [...m.keys()][i] ?? null,
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => void m.set(k, String(v)),
removeItem: (k) => void m.delete(k),
clear: () => m.clear(),
};
};
const at = "2026-10-17T12:00:00.000Z";
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
test("Back up now keeps its own list and never replaces the day's automatic snapshot", () => {
const storage = memory();
saveBackup(storage, state, at);
const automatic = readBackups(storage);
expect(saveManualBackup(storage, state, "2026-10-17T13:00:00.000Z").ok).toBe(
true,
);
expect(saveManualBackup(storage, state, "2026-10-17T14:00:00.000Z").ok).toBe(
true,
);
expect(readBackups(storage)).toEqual(automatic);
const manual = readManualBackups(storage);
expect(manual.map((b) => b.at)).toEqual([
"2026-10-17T14:00:00.000Z",
"2026-10-17T13:00:00.000Z",
]);
expect(storage.getItem(READER_KEYS.manualBackups)).not.toBeNull();
});
test("a manual backup can be deleted, and the list has a cap", () => {
const storage = memory();
saveManualBackup(storage, state, at);
const [only] = readManualBackups(storage);
expect(deleteManualBackup(storage, only!.id).ok).toBe(true);
expect(readManualBackups(storage)).toEqual([]);
for (let i = 0; i < MANUAL_BACKUP_LIMIT; i++)
expect(
saveManualBackup(
storage,
state,
new Date(Date.parse(at) + i).toISOString(),
).ok,
).toBe(true);
const full = saveManualBackup(storage, state, "2026-10-18T00:00:00.000Z");
expect(full).toEqual({ ok: false, reason: "full" });
expect(readManualBackups(storage)).toHaveLength(MANUAL_BACKUP_LIMIT);
});
test("one unreadable entry does not discard the other manual backups", () => {
const storage = memory();
saveManualBackup(storage, state, at);
const list = JSON.parse(storage.getItem(READER_KEYS.manualBackups)!);
storage.setItem(
READER_KEYS.manualBackups,
JSON.stringify([...list, { id: 1, at: "not a date" }]),
);
expect(readManualBackups(storage)).toHaveLength(1);
});
test("the list refuses a copy that would push it past its size budget", () => {
const storage = memory();
const big = {
...state,
prefs: { ...state.prefs, filler: "x".repeat(MANUAL_BACKUP_BYTES) },
};
expect(saveManualBackup(storage, big, at)).toEqual({
ok: false,
reason: "full",
});
expect(readManualBackups(storage)).toEqual([]);
});
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { ReaderStateSchema } from "../src/client/state/reader/schema.ts";
const at = "2026-10-16T12:00:00.000Z";
const raw = {
"gacha-tracker:v1:prefs": JSON.stringify({
region: "europe",
view: "timeline",
theme: "light",
hiddenGames: ["hsr"],
gameOrder: ["genshin", "hsr"],
showRoutines: true,
routinesHidden: ["routine:genshin:weekly-bosses"],
}),
"gacha-tracker:v1:progress": JSON.stringify({
"genshin:test:2026-10-01": { status: "done", at, goal: { future: true } },
"foreign-id": { note: "keep", at },
}),
"gacha-tracker:v1:daily": JSON.stringify({
"dailies:genshin": { days: ["2026-10-15"], at },
}),
"gacha-tracker:v1:tasks": JSON.stringify({
"mytask:abcdef": {
id: "mytask:abcdef",
game: "genshin",
title: "Mine",
period: { unit: "week", startsOn: 1 },
measure: { kind: "days", required: 1 },
at,
updatedAt: at,
future: 42,
},
}),
"gacha-tracker:v1:amounts": JSON.stringify({
"mytask:abcdef": { "2026-10-12": { value: 7, at } },
}),
};
test("migration preserves view, region, game choices and exact history keys", () => {
let n = 0;
const state = migrateLegacy(
raw,
["genshin", "hsr"],
at,
() => `account:test${++n}`,
);
expect(ReaderStateSchema.safeParse(state).success).toBe(true);
expect(state.prefs.view).toBe("timeline");
expect(state.prefs.hiddenGames).toEqual(["hsr"]);
const account = Object.values(state.accounts).find(
(a) => a.game === "genshin",
)!;
expect(account.region).toBe("europe");
const data = state.data[account.id]!;
expect(data.progress["genshin:test:2026-10-01"]).toEqual({
status: "done",
at,
goal: { future: true },
});
expect(data.daily[account.clocks[0]!.id]?.["dailies:genshin"]).toEqual({
days: ["2026-10-15"],
at,
});
expect(data.amounts[account.clocks[0]!.id]?.["mytask:abcdef"]).toEqual({
"2026-10-12": { value: 7, at },
});
expect(account.routines["routine:genshin:weekly-bosses"]?.followed).toBe(
false,
);
expect(state.tasks["mytask:abcdef"]).toHaveProperty("future", 42);
expect(raw["gacha-tracker:v1:progress"]).toContain("foreign-id");
});
test("completion-only installs and games absent from feed retain their state", () => {
const state = migrateLegacy(
{
"gacha-tracker:v1:completions": JSON.stringify({
"zzz:old:2026-01-01": { at },
}),
},
[],
at,
() => "account:old",
);
const account = Object.values(state.accounts)[0]!;
expect(account.game).toBe("zzz");
expect(state.data[account.id]?.progress["zzz:old:2026-01-01"]).toEqual({
status: "done",
at,
});
});
test("malformed blobs cannot hide valid state in other legacy keys", () => {
const state = migrateLegacy(
{ ...raw, "gacha-tracker:v1:tasks": "broken" },
["genshin"],
at,
() => "account:one",
);
expect(state.tasks).toEqual({});
expect(state.data["account:one"]?.progress).toHaveProperty(
"genshin:test:2026-10-01",
);
});
test("new catalogue games remain switched off for a returning reader", () => {
let n = 0;
const s = migrateLegacy(
{
"gacha-tracker:v1:prefs": JSON.stringify({
knownGames: ["genshin"],
hiddenGames: [],
onboarded: true,
}),
},
["genshin", "hsr"],
at,
() => `account:new${++n}`,
);
expect(s.prefs.hiddenGames).toEqual(["hsr"]);
expect(
Object.values(s.accounts).find((a) => a.game === "hsr")?.intent.events,
).toBe(false);
});
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test";
import { setupReader } from "../src/client/state/reader/onboarding.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
const at = "2026-10-16T12:00:00.000Z";
test("new-reader setup is events-only and remembers unsupported cycle intent", () => {
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
const next = setupReader(
{
region: "asia",
interface: "relaxed",
theme: "dark",
scheme: "slate",
timezone: null,
games: ["genshin"],
tracking: {
genshin: {
events: true,
dailies: false,
weeklyMonthly: false,
cycles: true,
},
},
showUpcoming: false,
view: "timeline",
},
state,
at,
() => "account:two",
);
const a = next.accounts["account:one"]!;
expect(a.region).toBe("asia");
expect(a.intent.cycles).toBe(true);
expect(a.routines["dailies:genshin"]?.followed).toBe(false);
expect(next.prefs.onboarded).toBe(true);
expect(next.prefs.view).toBe("timeline");
expect(next.prefs.showUpcoming).toBe(false);
});
test("the timeline design is saved only when the reader starts on Timeline", () => {
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
const draft = {
region: "europe" as const,
interface: "structured" as const,
theme: "dark" as const,
scheme: "slate",
timezone: null,
games: ["genshin"],
tracking: {},
showUpcoming: false,
view: "timeline" as const,
timelineLook: "classic" as const,
};
const mint = () => "account:two";
expect(setupReader(draft, state, at, mint).prefs.timelineLook).toBe(
"classic",
);
expect(
setupReader({ ...draft, view: "soon" }, state, at, mint).prefs.timelineLook,
).toBeUndefined();
});
+69
View File
@@ -0,0 +1,69 @@
import { expect, test } from "bun:test";
import {
prepareRestore,
finishPendingRestore,
undoRestore,
} from "../src/client/state/reader/restore.ts";
import { buildReaderExport } from "../src/client/state/reader/export.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
import { MemoryStorage } from "./helpers/reader.ts";
const at = "2026-10-16T12:00:00.000Z";
const reader = () => migrateLegacy({}, ["genshin"], at, () => "account:one");
test("restore preserves a recovery copy and undo restores the previous data", () => {
const s = new MemoryStorage(),
before = reader(),
after = { ...reader(), prefs: { interface: "relaxed" } };
s.setItem(READER_KEYS.reader, JSON.stringify(before));
expect(prepareRestore(s, buildReaderExport(after, {}, at), at)).toEqual({
ok: true,
});
expect(JSON.parse(s.getItem(READER_KEYS.reader)!).prefs.interface).toBe(
"relaxed",
);
expect(undoRestore(s)).toEqual({ ok: true });
expect(JSON.parse(s.getItem(READER_KEYS.reader)!)).toEqual(before);
});
test("failed live write keeps a prepared transaction for startup recovery", () => {
const s = new MemoryStorage();
s.setItem(READER_KEYS.reader, JSON.stringify(reader()));
s.fail = READER_KEYS.reader;
const after = { ...reader(), prefs: { interface: "relaxed" } };
expect(prepareRestore(s, buildReaderExport(after, {}, at), at)).toEqual({
ok: false,
reason: "quota",
});
expect(s.getItem(READER_KEYS.pending)).not.toBeNull();
expect(JSON.parse(s.getItem(READER_KEYS.reader)!).prefs.interface).toBe(
"structured",
);
s.fail = "";
expect(finishPendingRestore(s)).toEqual({ ok: true });
expect(JSON.parse(s.getItem(READER_KEYS.reader)!).prefs.interface).toBe(
"relaxed",
);
});
test("invalid payloads cannot overwrite live data or create a transaction", () => {
const s = new MemoryStorage();
s.setItem(READER_KEYS.reader, JSON.stringify(reader()));
expect(
prepareRestore(s, { format: "gacha-tracker-export", version: 999 }, at),
).toEqual({ ok: false, reason: "invalid" });
expect(s.getItem(READER_KEYS.pending)).toBeNull();
});
test("restore rejects orphan logs and references to missing clock revisions", () => {
const s = new MemoryStorage();
s.setItem(READER_KEYS.reader, JSON.stringify(reader()));
const broken = reader();
broken.data["account:missing"] = broken.data["account:one"]!;
expect(prepareRestore(s, buildReaderExport(broken, {}, at), at)).toEqual({
ok: false,
reason: "invalid",
});
delete broken.data["account:missing"];
broken.data["account:one"]!.daily.missing = {};
expect(prepareRestore(s, buildReaderExport(broken, {}, at), at)).toEqual({
ok: false,
reason: "invalid",
});
});
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import {
routineRows,
placementFor,
} from "../src/client/state/reader/routines.ts";
const at = "2026-10-16T12:00:00.000Z";
test("routine placements are independent and an absent new routine is not followed", () => {
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
const a = state.accounts["account:one"]!;
a.routines["routine:genshin:weekly-bosses"] = {
followed: true,
checklist: false,
timeline: true,
};
expect(placementFor(a, "new-routine")).toEqual({
followed: false,
checklist: false,
timeline: false,
});
const rows = routineRows(state, a.id, Date.parse(at));
expect(
rows.find((r) => r.id === "routine:genshin:weekly-bosses")?.placement,
).toEqual({ followed: true, checklist: false, timeline: true });
});
test("a changed reset reads only its own current revision without deleting older history", () => {
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
const a = state.accounts["account:one"]!;
a.clocks.push({
...a.clocks[0]!,
id: "changed",
effectiveAt: at,
reset: { hour: 5, minute: 30 },
});
state.data[a.id]!.daily.initial = {
"dailies:genshin": { days: ["2026-10-16"], at },
};
const row = routineRows(state, a.id, Date.parse(at)).find(
(r) => r.id === "dailies:genshin",
)!;
expect(row.days).toEqual([]);
expect(state.data[a.id]!.daily.initial).toHaveProperty("dailies:genshin");
});
import { sampleEvent } from "./helpers/reader.ts";
test("an event the reader marked daily remains a directly tickable routine", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.seenEvents.x = {
accountId: "account:one",
event: sampleEvent,
firstSeenAt: at,
lastSeenAt: at,
clockRevision: "initial",
};
s.data["account:one"]!.progress[sampleEvent.id] = { daily: true, at };
const row = routineRows(s, "account:one", Date.parse(at)).find(
(r) => r.id === sampleEvent.id,
);
expect(row?.placement.followed).toBe(true);
expect(row?.cadence).toBe("day");
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { createAccount } from "../src/client/state/reader/accounts.ts";
import {
manyServers,
serverSuffix,
} from "../src/client/state/reader/servers.ts";
const at = "2026-10-16T12:00:00.000Z";
test("a server is named only for a game tracked on more than one", () => {
const s = migrateLegacy(
{},
["genshin", "hsr"],
at,
() => `account:${Math.random()}`,
);
expect(manyServers(s, "genshin")).toBe(false);
expect(serverSuffix(s, "genshin", "europe")).toBe("");
s.accounts["account:asia"] = createAccount(
"genshin",
"asia",
at,
"account:asia",
);
expect(serverSuffix(s, "genshin", "asia")).toBe(" · asia");
expect(serverSuffix(s, "hsr", "europe")).toBe("");
// A retired second server stops counting, except where archives ask.
s.accounts["account:asia"] = {
...s.accounts["account:asia"]!,
retiredAt: at,
};
expect(manyServers(s, "genshin")).toBe(false);
expect(manyServers(s, "genshin", true)).toBe(true);
});
+217
View File
@@ -0,0 +1,217 @@
import { MemoryStorage } from "./helpers/reader.ts";
import { expect, test } from "bun:test";
import {
createReaderStore,
initializeReader,
} from "../src/client/state/reader/store.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
const at = "2026-10-16T12:00:00.000Z";
test("two stores rebase unrelated field changes and reject older same-field writes", async () => {
const storage = new MemoryStorage();
const initial = migrateLegacy({}, ["genshin"], at, () => "account:one");
const a = createReaderStore(storage, initial),
b = createReaderStore(storage, initial);
expect(
await a.dispatch({
type: "progress",
accountId: "account:one",
itemId: "genshin:a:2026-10-01",
patch: { status: "doing" },
at,
opId: "a",
}),
).toEqual({ ok: true });
expect(
await b.dispatch({
type: "progress",
accountId: "account:one",
itemId: "genshin:a:2026-10-01",
patch: { note: "Keep me" },
at,
opId: "b",
}),
).toEqual({ ok: true });
const persisted = JSON.parse(storage.getItem(READER_KEYS.reader)!);
expect(
persisted.data["account:one"].progress["genshin:a:2026-10-01"],
).toMatchObject({ status: "doing", note: "Keep me" });
expect(
await a.dispatch({
type: "progress",
accountId: "account:one",
itemId: "genshin:a:2026-10-01",
patch: { status: "done" },
at: "2026-10-15T12:00:00.000Z",
opId: "c",
}),
).toEqual({ ok: false, reason: "conflict" });
});
test("operation replay is idempotent, including toggles", async () => {
const storage = new MemoryStorage(),
s = createReaderStore(
storage,
migrateLegacy({}, ["genshin"], at, () => "account:one"),
);
const action = {
type: "day" as const,
accountId: "account:one",
itemId: "dailies:genshin",
revisionId: "initial",
day: "2026-10-16",
checked: true,
at,
opId: "same",
};
await s.dispatch(action);
const rev = s.getSnapshot().revision;
await s.dispatch(action);
expect(s.getSnapshot().revision).toBe(rev);
});
test("failed migration preserves legacy bytes and never records success", () => {
const storage = new MemoryStorage();
storage.setItem("gacha-tracker:v1:prefs", '{"onboarded":true}');
storage.fail = READER_KEYS.reader;
const result = initializeReader(
storage,
["genshin"],
at,
() => "account:one",
);
expect(result.ok).toBe(false);
expect(storage.getItem("gacha-tracker:v1:prefs")).toBe('{"onboarded":true}');
expect(storage.getItem(READER_KEYS.reader)).toBeNull();
expect(storage.getItem(READER_KEYS.migrationRecovery)).not.toBeNull();
});
test("quota failure does not publish a successful in-memory edit", async () => {
const storage = new MemoryStorage(),
s = createReaderStore(
storage,
migrateLegacy({}, ["genshin"], at, () => "account:one"),
);
storage.fail = READER_KEYS.reader;
expect(
await s.dispatch({
type: "prefs",
patch: { interface: "relaxed" },
at,
opId: "a",
}),
).toEqual({ ok: false, reason: "quota" });
expect(s.getSnapshot().prefs.interface).toBe("structured");
});
test("new-event markers seed accounts together rather than queueing per-account writes", async () => {
const storage = new MemoryStorage();
let n = 0;
const s = createReaderStore(
storage,
migrateLegacy({}, ["genshin", "hsr"], at, () => `account:test${++n}`),
);
await s.dispatch({
type: "seenBatch",
entries: [
{ id: "account:test1", ids: ["genshin:a"] },
{ id: "account:test2", ids: ["hsr:a"] },
],
at,
opId: "batch",
});
expect(s.getSnapshot().revision).toBe(1);
expect(s.getSnapshot().eventSeen).toEqual({
"account:test1": ["genshin:a"],
"account:test2": ["hsr:a"],
});
});
test("stale tabs preserve independent account routine choices", async () => {
const storage = new MemoryStorage(),
initial = migrateLegacy({}, ["genshin"], at, () => "account:one");
storage.setItem(READER_KEYS.reader, JSON.stringify(initial));
const a = createReaderStore(storage, initial),
b = createReaderStore(storage, initial),
account = initial.accounts["account:one"]!;
await a.dispatch({
type: "account",
account: {
...account,
routines: {
...account.routines,
first: { followed: true, checklist: true, timeline: false },
},
},
at,
opId: "first",
});
await b.dispatch({
type: "account",
account: {
...account,
routines: {
...account.routines,
second: { followed: true, checklist: false, timeline: true },
},
},
at,
opId: "second",
});
expect(b.getSnapshot().accounts[account.id]!.routines.first).toHaveProperty(
"followed",
true,
);
expect(b.getSnapshot().accounts[account.id]!.routines.second).toHaveProperty(
"followed",
true,
);
});
test("resuming a retired account removes its retirement without erasing history", async () => {
const storage = new MemoryStorage(),
initial = migrateLegacy({}, ["genshin"], at, () => "account:one");
initial.accounts["account:one"]!.retiredAt = at;
storage.setItem(READER_KEYS.reader, JSON.stringify(initial));
const store = createReaderStore(storage, initial),
account = { ...initial.accounts["account:one"]! };
delete account.retiredAt;
expect(
await store.dispatch({ type: "account", account, at, opId: "resume" }),
).toEqual({ ok: true });
expect(store.getSnapshot().accounts[account.id]!.retiredAt).toBeUndefined();
});
test("stale tabs preserve independent pins on the same routine", async () => {
const storage = new MemoryStorage(),
initial = migrateLegacy({}, ["genshin"], at, () => "account:one"),
account = initial.accounts["account:one"]!;
account.routines.test = { followed: true, checklist: false, timeline: false };
storage.setItem(READER_KEYS.reader, JSON.stringify(initial));
const a = createReaderStore(storage, initial),
b = createReaderStore(storage, initial);
await a.dispatch({
type: "account",
account: {
...account,
routines: {
...account.routines,
test: { ...account.routines.test!, checklist: true },
},
},
at,
opId: "checklist",
});
await b.dispatch({
type: "account",
account: {
...account,
routines: {
...account.routines,
test: { ...account.routines.test!, timeline: true },
},
},
at,
opId: "timeline",
});
expect(b.getSnapshot().accounts[account.id]!.routines.test).toEqual({
followed: true,
checklist: true,
timeline: true,
});
});
+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from "bun:test";
import {
staleSnooze,
updateItems,
} from "../src/client/state/reader/updates.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import type { EventFeed } from "../src/shared/feed.ts";
const at = "2026-10-16T12:00:00.000Z";
test("source warning follows tracked games, independent of focus; empty updates disappear", () => {
const s = migrateLegacy(
{},
["genshin", "hsr"],
at,
(() => {
let n = 0;
return () => `account:a${++n}`;
})(),
);
s.accounts["account:a2"]!.intent.events = false;
s.accounts["account:a2"]!.routines = {};
s.prefs.focusGame = "hsr";
const source = {
sourceId: "genshin-events",
game: "genshin" as const,
url: "https://example.com",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
eventCount: 0,
parsedCount: null,
statesNoEvents: false,
};
const feed: EventFeed = {
schemaVersion: 1,
generatedAt: at,
events: [],
sources: [source],
};
expect(updateItems(s, feed, Date.parse(at)).map((r) => r.kind)).toEqual([
"sources",
]);
expect(
updateItems(
s,
{ ...feed, sources: [{ ...source, lastSuccessAt: at }] },
Date.parse(at),
),
).toEqual([]);
});
test("a snoozed source warning stays hidden until it expires or another game goes stale", () => {
const s = migrateLegacy(
{},
["genshin", "hsr"],
at,
(() => {
let n = 0;
return () => `account:a${++n}`;
})(),
);
const source = (game: "genshin" | "hsr") => ({
sourceId: `${game}-events`,
game,
url: "https://example.com",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
eventCount: 0,
parsedCount: null,
statesNoEvents: false,
});
const feed: EventFeed = {
schemaVersion: 1,
generatedAt: at,
events: [],
sources: [source("genshin")],
};
const now = Date.parse(at),
day = 86_400_000;
s.prefs.staleSnooze = staleSnooze(["genshin"], now, 7);
expect(s.prefs.staleSnooze).toEqual({
until: new Date(now + 7 * day).toISOString(),
games: ["genshin"],
});
expect(updateItems(s, feed, now + 6 * day)).toEqual([]);
expect(updateItems(s, feed, now + 7 * day).map((r) => r.kind)).toEqual([
"sources",
]);
expect(
updateItems(
s,
{ ...feed, sources: [source("genshin"), source("hsr")] },
now + day,
),
).toEqual([{ kind: "sources", games: ["genshin", "hsr"] }]);
// Never longer than seven days, whatever is asked.
expect(staleSnooze(["genshin"], now, 30).until).toBe(
new Date(now + 7 * day).toISOString(),
);
s.prefs.staleSnooze = "garbage";
expect(updateItems(s, feed, now).map((r) => r.kind)).toEqual(["sources"]);
});
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { ChangelogPage } from "../src/client/components/redesign/ChangelogPage.tsx";
import { ENTRIES, NOTES } from "../src/shared/changelog.ts";
test("the changelog keeps every commit, its kind and the note's specs in the mockup layout", () => {
const html = render(<ChangelogPage notes={NOTES} entries={ENTRIES} />);
expect(html).toContain('class="rel"');
for (const entry of ENTRIES) expect(html).toContain(`>${entry.ref}<`);
for (const label of ["New", "Fixed", "Games &amp; sources"])
expect(html).toContain(label);
const withSpec = NOTES.find((n) => (n.specs ?? []).length > 0);
if (withSpec)
expect(html).toContain(`Spec: ${withSpec.specs![0]!.split("/").at(-1)}`);
expect(html).toContain("Other changes");
});
+54
View File
@@ -0,0 +1,54 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import {
ChecklistEvent,
LedgerColumns,
NextDeadline,
} from "../src/client/components/redesign/ChecklistEvent.tsx";
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { sampleEvent } from "./helpers/reader.ts";
const at = "2026-10-16T12:00:00.000Z";
const rows = () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
const events = [1, 2, 3, 4, 5].map((n) => ({
...sampleEvent,
id: `${sampleEvent.id}-${n}`,
title: `Event ${n}`,
summary: null,
endsAt: new Date(Date.parse(at) + n * 86_400_000).toISOString(),
}));
return readerEventRows(s, events, Date.parse(at)).filter((r) => r.clock.live);
};
test("every row has a time-left bar, with or without a description", () => {
const [row] = rows();
const html = render(
<ChecklistEvent
row={row!}
now={Date.parse(at)}
compact
onOpen={() => {}}
/>,
);
expect(html).toContain('class="ledger-row"');
expect(html).toContain('class="gcell"');
expect(html).toContain('class="meter"');
expect(html).not.toContain("event-entry");
expect(render(<LedgerColumns />)).toContain("Time left");
// Standard rows without a description show the time-left bar too (the
// owner chose this over the mockup's shorter row).
expect(
render(
<ChecklistEvent row={row!} now={Date.parse(at)} onOpen={() => {}} />,
),
).toContain('class="meter"');
});
test("the next deadline lists the following three, as in the Round 6 rail", () => {
const live = rows();
const html = render(
<NextDeadline row={live[0]} then={live.slice(1, 4)} onOpen={() => {}} />,
);
expect(html).toContain('class="then"');
for (const n of [2, 3, 4]) expect(html).toContain(`Event ${n}`);
expect(html).not.toContain("Event 5");
});
+128
View File
@@ -0,0 +1,128 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { DailyPanel } from "../src/client/components/redesign/DailyPanel.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { routineRows } from "../src/client/state/reader/routines.ts";
import { readerDayKey } from "../src/shared/readerClock.ts";
const at = "2026-10-16T12:00:00.000Z";
const now = Date.parse(at);
function setup() {
const state = migrateLegacy({}, ["genshin"], at, () => "account:daily");
state.accounts["account:daily"]!.routines["dailies:genshin"] = {
followed: true,
checklist: true,
timeline: false,
};
const rows = routineRows(state, "account:daily", now).filter(
(r) => r.id === "dailies:genshin",
);
return { state, rows };
}
test("a daily row is one tick target, with progress above the list", () => {
const { state, rows } = setup();
const html = render(
<DailyPanel
rows={rows}
state={state}
onAction={async () => ({ ok: true })}
now={now}
/>,
);
expect(html).toContain('class="daily-row daily-hit"');
expect(html).toContain('aria-pressed="false"');
expect(html).toContain("0 of 1 done");
expect(html).not.toContain("-day streak");
});
test("a ticked daily reads done, shows its streak, and the panel says all done", () => {
const { state, rows } = setup();
const today = readerDayKey(now, rows[0]!.clock);
const yesterday = new Date(Date.parse(`${today}T00:00:00Z`) - 86_400_000)
.toISOString()
.slice(0, 10);
const ticked = [{ ...rows[0]!, days: [yesterday, today] }];
const html = render(
<DailyPanel
rows={ticked}
state={state}
onAction={async () => ({ ok: true })}
now={now}
/>,
);
expect(html).toContain('aria-pressed="true"');
expect(html).toContain("All done for today");
expect(html).toContain("2-day streak");
// A fresh render is not a fresh tick: nothing pops on arrival.
expect(html).not.toContain("data-fresh");
expect(html).not.toContain("fireworks");
});
test("an event's weekly goal, met without today's tick, reads done and shows 5/5 wk", () => {
const { state, rows } = setup();
const today = readerDayKey(now, rows[0]!.clock);
const goalRow = {
...rows[0]!,
id: "genshin:to-temper-thyself",
title: "To Temper Thyself and Journey Far",
days: [],
status: {
scope: "week" as const,
measure: "days" as const,
periodStart: today,
required: 5,
count: 5,
met: true,
needed: 0,
open: 0,
shortfall: false,
},
};
const html = render(
<DailyPanel
rows={[goalRow]}
state={state}
onAction={async () => ({ ok: true })}
now={now}
/>,
);
expect(html).toContain("5/5 wk");
expect(html).toContain('data-met="true"');
// Not ticked today: the button stays unpressed, nothing is invented.
expect(html).toContain('aria-pressed="false"');
expect(html).toContain("All done for today");
expect(html).not.toContain("-day streak");
});
test("an unmet weekly goal counts toward its target, not as done", () => {
const { state, rows } = setup();
const today = readerDayKey(now, rows[0]!.clock);
const goalRow = {
...rows[0]!,
id: "genshin:to-temper-thyself",
days: [],
status: {
scope: "week" as const,
measure: "days" as const,
periodStart: today,
required: 5,
count: 3,
met: false,
needed: 2,
open: 4,
shortfall: false,
},
};
const html = render(
<DailyPanel
rows={[goalRow]}
state={state}
onAction={async () => ({ ok: true })}
now={now}
/>,
);
expect(html).toContain("3/5 wk");
expect(html).toContain("0 of 1 done");
expect(html).not.toContain("data-met");
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { SetupWizard } from "../src/client/components/redesign/SetupWizard.tsx";
import { ReturningTour } from "../src/client/components/redesign/ReturningTour.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
test("initial preferences follow the Round 6 setup and defer upcoming events to tracking", () => {
const s = migrateLegacy(
{},
[],
"2026-10-16T12:00:00.000Z",
() => "account:test",
);
const html = render(<SetupWizard state={s} onFinish={async () => {}} />);
for (const label of [
"Step 1 of 4",
"Make it yours",
"Default game server",
"Appearance",
"Colour scheme",
"Your timezone",
"Next: choose your games",
])
expect(html).toContain(label);
expect(html).not.toContain("Show events that haven&#x27;t started");
});
test("returning readers can browse immediately without restarting setup", () => {
const html = render(<ReturningTour onStep={() => {}} onFinish={() => {}} />);
expect(html).toContain("Your games, routines and progress are all still here");
expect(html).toContain("Keep browsing");
expect(html).toContain("Take the quick tour");
});
import { TOUR_STEPS } from "../src/client/components/redesign/ReturningTour.tsx";
test("the tour introduces the Timeline right after the Checklist, and codes", () => {
const stops = TOUR_STEPS.map((s) => s.stop);
expect(stops.indexOf("timeline")).toBe(stops.indexOf("soon") + 1);
expect(stops).toContain("codes");
});
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import {
PinnedRoutine,
lastDayInPeriod,
} from "../src/client/components/redesign/PinnedRoutine.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { routineRows } from "../src/client/state/reader/routines.ts";
const at = "2026-10-16T12:00:00.000Z";
const now = Date.parse(at);
function row(id: string) {
const state = migrateLegacy({}, ["genshin"], at, () => "account:pin");
state.accounts["account:pin"]!.routines[id] = {
followed: true,
checklist: true,
timeline: false,
};
return routineRows(state, "account:pin", now).find((r) => r.id === id)!;
}
const ok = async () => ({ ok: true }) as const;
test("an amount routine short of its target offers +1, an editable count and one-click complete", () => {
const r = row("routine:genshin:weekly-bosses");
const html = render(
<PinnedRoutine row={r} hue="#4EA8DE" onAction={ok} now={now} />,
);
expect(html).toContain("0 / 3 runs");
expect(html).toContain("+1");
expect(html).toContain("Mark Weekly Bosses complete");
expect(html).toContain('aria-pressed="false"');
expect(html).not.toContain("data-done");
});
test("a met target reads done this week, drops +1, and its box offers undo", () => {
const r = row("routine:genshin:weekly-bosses");
const met = { ...r, status: { ...r.status, count: 3, met: true, needed: 0 } };
const html = render(
<PinnedRoutine row={met} hue="#4EA8DE" onAction={ok} now={now} />,
);
expect(html).toContain('data-done="true"');
expect(html).toContain("Done this week");
expect(html).toContain("3 / 3 runs");
expect(html).not.toContain("+1");
expect(html).toContain("done this week. Undo");
expect(html).toContain('aria-pressed="true"');
// Arriving complete is not completing it: no fireworks on render.
expect(html).not.toContain("fireworks");
});
test("undoing a check-in removes the period's latest tick, not one from before it", () => {
const r = row("routine:genshin:realm-depot");
const today = "2026-10-16";
const ticked = {
...r,
days: ["2026-10-08", "2026-10-13", "2026-10-14"],
status: { ...r.status, periodStart: "2026-10-12" },
};
expect(lastDayInPeriod(ticked, today)).toBe("2026-10-14");
expect(
lastDayInPeriod({ ...ticked, days: ["2026-10-08"] }, today),
).toBeNull();
});
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import {
RoutineDrawer,
scheduleWords,
sourceLabel,
} from "../src/client/components/redesign/RoutineDrawer.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { createAccount } from "../src/client/state/reader/accounts.ts";
const at = "2026-10-16T12:00:00.000Z";
const drawer = (
pane: "library" | "create",
state = migrateLegacy({}, ["genshin"], at, () => "account:one"),
) =>
render(
<RoutineDrawer
state={state}
now={Date.parse(at)}
pane={pane}
editing={null}
onPane={() => {}}
onClose={() => {}}
onAction={async () => ({ ok: true })}
onSave={async () => true}
/>,
);
test("schedule lines read like the mockup", () => {
expect(scheduleWords({ unit: "week", startsOn: 1 }, 4)).toBe(
"Every Monday at 04:00",
);
expect(scheduleWords({ unit: "month", startsOn: 16 }, 4)).toBe(
"Every month on the 16th at 04:00",
);
expect(scheduleWords({ unit: "day" }, 20)).toBe("Every day at 20:00");
expect(
sourceLabel("https://genshin-impact.fandom.com/wiki/Weekly_Boss"),
).toBe("Community wiki");
});
test("the library is a shelf of built-ins with bulk follows and dated sources", () => {
const html = drawer("library");
expect(html).toContain('class="routine-drawer"');
expect(html).toContain("Create your own routine");
expect(html).toContain("New follows also show in");
expect(html).toContain("Follow all weeklies");
expect(html).toContain("Every Monday at 04:00 · 3 runs · Community wiki");
expect(html).toContain(">Follow<");
});
test("a game on two servers gets one follow button per server", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.accounts["account:asia"] = createAccount(
"genshin",
"asia",
at,
"account:asia",
);
s.data["account:asia"] = {
progress: {},
ignored: {},
daily: {},
amounts: {},
};
const html = drawer("library", s);
expect(html).toContain(">EU<");
expect(html).toContain(">Asia<");
expect(html).toContain("2 servers");
});
test("the create shelf asks every mockup question and previews the result", () => {
const html = drawer("create");
for (const label of [
"Start from an example",
"What is it",
"Repeats",
"Every day",
"Resets at",
"Another hour",
"Done when",
"Note",
"Link",
"Also show in",
"Preview",
"Create and follow",
])
expect(html).toContain(label);
expect(html).toContain("Still needed: a name.");
});
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { RoutineGroup } from "../src/client/components/redesign/RoutineGroup.tsx";
test("always expanded groups have no collapse affordance", () => {
const html = render(
<RoutineGroup title="Genshin" policy="always" focused={false}>
<p>Direct daily controls</p>
</RoutineGroup>,
);
expect(html).not.toContain("<summary");
expect(html).toContain("Direct daily controls");
});
test("focus expands groups without changing the saved default", () => {
const html = render(
<RoutineGroup title="Genshin" policy="collapsed" focused={true}>
<p>Controls</p>
</RoutineGroup>,
);
expect(html).toContain('open=""');
});
import { placementAccounts } from "../src/client/components/redesign/RoutinesPage.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { routineRows } from "../src/client/state/reader/routines.ts";
test("bulk pins combine changes per account and preserve the other placement", () => {
const at = "2026-10-16T12:00:00.000Z";
const state = migrateLegacy({}, ["genshin"], at, () => "account:bulk");
const a = state.accounts["account:bulk"]!;
a.routines["routine:genshin:weekly-bosses"] = {
followed: true,
checklist: false,
timeline: true,
};
const rows = routineRows(state, a.id, Date.parse(at));
expect(rows.length).toBeGreaterThan(1);
const accounts = placementAccounts(state, rows, "checklist", true);
expect(accounts).toHaveLength(1);
for (const row of rows) {
expect(accounts[0]!.routines[row.id]!.checklist).toBe(true);
expect(accounts[0]!.routines[row.id]!.timeline).toBe(
row.placement.timeline,
);
}
expect(a.routines["routine:genshin:weekly-bosses"]!.checklist).toBe(false);
});
import { RoutinesPage } from "../src/client/components/redesign/RoutinesPage.tsx";
test("the routines table shows progress as text with an edit affordance, not inputs", () => {
const at = "2026-10-16T12:00:00.000Z";
const state = migrateLegacy({}, ["genshin"], at, () => "account:table");
const pin = { followed: true, checklist: true, timeline: true };
state.accounts["account:table"]!.routines["routine:genshin:weekly-bosses"] =
pin;
state.accounts["account:table"]!.routines["dailies:genshin"] = pin;
const html = render(
<RoutinesPage
state={state}
onAction={async () => ({ ok: true })}
now={Date.parse(at)}
/>,
);
expect(html).toContain('class="rt"');
expect(html).toContain('class="gcell"');
expect(html).toContain('title="Enter an amount"');
expect(html).toContain("/ 3 runs");
expect(html).not.toContain('type="number"');
expect(html).toContain('class="pin"');
// One server: the game cell does not name it.
expect(html).not.toContain(">europe<");
});
test("routine bands are named by cadence: Dailies, Weeklies", () => {
const at = "2026-10-16T12:00:00.000Z";
const state = migrateLegacy({}, ["genshin"], at, () => "account:bands");
const pin = { followed: true, checklist: true, timeline: true };
state.accounts["account:bands"]!.routines["routine:genshin:weekly-bosses"] =
pin;
state.accounts["account:bands"]!.routines["dailies:genshin"] = pin;
const html = render(
<RoutinesPage
state={state}
onAction={async () => ({ ok: true })}
now={Date.parse(at)}
/>,
);
expect(html).toContain(">Dailies<");
expect(html).toContain(">Weeklies<");
expect(html).not.toContain(">Today<");
expect(html).not.toContain(">This week<");
});
import { Fireworks } from "../src/client/components/Fireworks.tsx";
test("the grand celebration is wider than the single burst and carries the game's colour", () => {
const single = render(<Fireworks />);
const grand = render(<Fireworks grand hue="#4EA8DE" />);
const count = (html: string) => html.split('class="spark"').length - 1;
expect(count(single)).toBe(27);
expect(count(grand)).toBe(98);
expect(grand).toContain('class="fireworks grand"');
expect(grand).toContain("background:#4EA8DE");
});
test("a finished routine group is marked complete but does not celebrate on arrival", () => {
const html = render(
<RoutineGroup
title="Genshin"
policy="always"
focused={false}
done={2}
total={2}
summary="All 2 done"
>
<p>rows</p>
</RoutineGroup>,
);
expect(html).toContain('data-complete="true"');
expect(html).toContain("All 2 done");
expect(html).not.toContain("data-celebrate");
expect(html).not.toContain("fireworks");
});
+103
View File
@@ -0,0 +1,103 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { AboutPage } from "../src/client/components/redesign/AboutPage.tsx";
import {
DataSourcesPage,
sourceReason,
} from "../src/client/components/redesign/DataSourcesPage.tsx";
import type { SourceHealth } from "../src/shared/feed.ts";
const now = Date.parse("2026-10-17T12:00:00.000Z");
const hoursAgo = (h: number) => new Date(now - h * 3_600_000).toISOString();
const source = (over: Partial<SourceHealth> = {}): SourceHealth => ({
sourceId: "genshin-events",
game: "genshin",
url: "https://genshin-impact.fandom.com/wiki/Event",
lastSuccessAt: hoursAgo(2),
lastConfirmedAt: hoursAgo(2),
contentChangedAt: hoursAgo(6),
eventCount: 3,
parsedCount: null,
statesNoEvents: false,
...over,
});
test("About holds credits and the disclaimer, not data freshness", () => {
const html = render(<AboutPage sources={[source()]} />);
for (const part of [
"Made by",
"With thanks to the source editors",
"The studios and games",
"Ideas and acknowledgements",
"Unofficial and unaffiliated",
"Genshin Impact",
])
expect(html).toContain(part);
expect(html).toContain('class="game-name" style="color:');
expect(html).not.toContain("See data freshness");
expect(html).not.toContain("last refreshed");
});
test("Data & sources holds the refresh time and one row per game", () => {
const html = render(
<DataSourcesPage
sources={[
source(),
source({ sourceId: "genshin-game8", url: "https://game8.co/x" }),
]}
now={now}
timezone={null}
onRefresh={async () => "same"}
/>,
);
expect(html).toContain("Event data last refreshed");
expect(html).toContain("Check for new data");
expect(html.match(/class="source-row"/g)?.length).toBe(1);
expect(html).toContain("Last successful check:");
expect(html).toContain("Check source page");
});
test("each source reads as one of the mockup's four states", () => {
expect(sourceReason(source(), now).current).toBe(true);
expect(
sourceReason(source({ contentChangedAt: hoursAgo(24 * 9) }), now).text,
).toContain("Site has no new updates");
expect(
sourceReason(
source({
lastSuccessAt: hoursAgo(24 * 6),
lastConfirmedAt: hoursAgo(24 * 6),
}),
now,
).text,
).toContain("Pull overdue");
expect(
sourceReason(
source({
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
}),
now,
).text,
).toContain("No successful source fetch");
});
test("Data & sources offers the standard-banner pulls choice beside the other list options", () => {
const html = render(
<DataSourcesPage
sources={[source()]}
now={now}
timezone={null}
showUpcoming={false}
detectDaily={false}
standardPulls={true}
onPrefs={() => {}}
/>,
);
expect(html).toContain("Offer standard-banner pulls in the library");
expect(html).toContain("Premium pulls are always offered.");
expect(html.indexOf("Offer standard-banner pulls")).toBeLessThan(
html.indexOf('class="source-row"'),
);
});
+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import {
AppNavigation,
mainViewPatch,
} from "../src/client/components/redesign/AppNavigation.tsx";
import { SettingsLayout } from "../src/client/components/redesign/SettingsLayout.tsx";
import { ChangelogPage } from "../src/client/components/redesign/ChangelogPage.tsx";
test("utility pages do not replace the reader's preferred main view", () => {
expect(mainViewPatch("settings")).toEqual({});
expect(mainViewPatch("archive")).toEqual({});
expect(mainViewPatch("timeline")).toEqual({ view: "timeline" });
const html = render(<AppNavigation page="settings" onPage={() => {}} />);
for (const label of [
"Checklist",
"Timeline",
"Routines",
"Archive",
"Settings",
])
expect(html).toContain(label);
expect(html).toContain('aria-current="page"');
});
test("settings has named navigation and one content region", () => {
const html = render(
<SettingsLayout page="appearance" onPage={() => {}}>
<p>Appearance controls</p>
</SettingsLayout>,
);
expect(html).toContain('aria-label="Settings pages"');
expect(html).toContain("Appearance controls");
expect(html).toContain("Changelog");
});
test("changelog page has release content without a scrim or modal", () => {
const html = render(
<ChangelogPage
notes={[]}
entries={[
{
id: "test",
date: "2026-10-16",
title: "A real entry",
kind: "feature",
ref: "abcdef1",
},
]}
/>,
);
expect(html).toContain("A real entry");
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain("Close changelog");
});
import { NextUp } from "../src/client/components/NextUp.tsx";
import { clockFor } from "../src/shared/time.ts";
import { sampleEvent } from "./helpers/reader.ts";
test("headline identifies the server when accounts share an event title", () => {
const html = render(
<NextUp
rows={[
{
event: sampleEvent,
clock: clockFor(
sampleEvent,
"asia",
Date.parse("2026-10-16T12:00:00Z"),
),
accountId: "account:asia",
region: "asia",
},
]}
focused={null}
onOpen={() => {}}
/>,
);
expect(html).toContain("Asia");
});
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import {
zoneGroups,
zoneName,
} from "../src/client/components/redesign/TimezoneSelect.tsx";
import { TimeSettings } from "../src/client/components/redesign/TimeSettings.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
test("zones read as cities, grouped by region", () => {
expect(zoneName("Europe/Copenhagen")).toBe("Copenhagen");
expect(zoneName("America/Argentina/Buenos_Aires")).toBe(
"Buenos Aires, Argentina",
);
const groups = zoneGroups([
"Europe/Paris",
"UTC",
"Asia/Tokyo",
"Europe/Berlin",
]);
expect(groups.map((g) => g.group)).toEqual(["Europe", "Asia", "Other"]);
expect(groups[0]!.zones.map((z) => z.name)).toEqual(["Berlin", "Paris"]);
});
test("Region & time has one timezone list, no search box, and a reset row per server", () => {
const at = "2026-10-16T12:00:00.000Z";
const s = migrateLegacy(
{},
["genshin", "hsr"],
at,
(() => {
let n = 0;
return () => `account:a${++n}`;
})(),
);
const html = render(
<TimeSettings
state={s}
now={Date.parse(at)}
onAction={async () => ({ ok: true })}
/>,
);
expect(html).not.toContain('type="search"');
expect(html).toContain('<optgroup label="Europe">');
expect(html).toContain("Currently displaying");
expect(html.match(/class="reset-row"/g)?.length).toBe(2);
expect(html).toContain("Reset timing for your games");
});
+174
View File
@@ -0,0 +1,174 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import {
TimelinePage,
timelineLook,
} from "../src/client/components/redesign/TimelinePage.tsx";
import { focusBarShown } from "../src/client/components/redesign/FocusPicker.tsx";
import { openingLeft } from "../src/client/components/Timeline.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
import { sampleEvent } from "./helpers/reader.ts";
test("a game's reset strip follows its events and precedes its full routine rows", () => {
const at = "2026-10-16T12:00:00.000Z",
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
s.prefs.resetStrips = true;
s.accounts["account:test"]!.routines["routine:genshin:weekly-bosses"] = {
followed: true,
checklist: true,
timeline: true,
};
const html = render(
<TimelinePage
state={s}
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
now={Date.parse(at)}
onAction={async () => ({ ok: true })}
onOpen={() => {}}
/>,
);
expect(html.indexOf('class="reset-strip"')).toBeGreaterThan(
html.indexOf("View Test"),
);
expect(html.indexOf('class="routine-timeline-row"')).toBeGreaterThan(
html.indexOf('class="reset-strip"'),
);
// A divider says where the routines begin.
expect(html.indexOf('class="routine-divider"')).toBeGreaterThan(
html.indexOf('class="reset-strip"'),
);
expect(html).toContain("Show routines in own section");
});
test("focus excludes other games' routine-only timelines", () => {
let n = 0;
const at = "2026-10-16T12:00:00.000Z",
s = migrateLegacy({}, ["genshin", "hsr"], at, () => `account:test${++n}`);
s.prefs.focusGame = "genshin";
for (const a of Object.values(s.accounts))
a.routines[`dailies:${a.game}`] = {
followed: true,
checklist: true,
timeline: true,
};
const html = render(
<TimelinePage
state={s}
rows={readerEventRows(s, [sampleEvent], Date.parse(at)).filter(
(r) => r.event.game === "genshin",
)}
now={Date.parse(at)}
onAction={async () => ({ ok: true })}
onOpen={() => {}}
/>,
);
expect(html).not.toContain("Honkai: Star Rail");
});
test("Ending soonest places pinned routine resets in the same deadline queue as events", () => {
const at = "2026-10-16T12:00:00.000Z",
now = Date.parse(at),
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
s.prefs.timelineGroup = "ending";
s.accounts["account:test"]!.routines["routine:genshin:weekly-bosses"] = {
followed: true,
checklist: true,
timeline: true,
};
const html = render(
<TimelinePage
state={s}
rows={readerEventRows(
s,
[{ ...sampleEvent, endsAt: "2026-10-30T12:00:00.000Z" }],
now,
)}
now={now}
onAction={async () => ({ ok: true })}
onOpen={() => {}}
/>,
);
expect(html.indexOf('class="routine-timeline-row"')).toBeGreaterThan(-1);
expect(html.indexOf('class="routine-timeline-row"')).toBeLessThan(
html.indexOf('aria-label="View Test details"'),
);
});
test("dailies stay off the timeline even when pinned there; longer routines show", () => {
const at = "2026-10-16T12:00:00.000Z",
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
const pin = { followed: true, checklist: true, timeline: true };
s.accounts["account:test"]!.routines["dailies:genshin"] = pin;
s.accounts["account:test"]!.routines["routine:genshin:weekly-bosses"] = pin;
const html = render(
<TimelinePage
state={s}
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
now={Date.parse(at)}
onAction={async () => ({ ok: true })}
onOpen={() => {}}
/>,
);
expect(html).toContain("Weekly Bosses");
expect(html).not.toContain("Commissions, resin");
});
test("the new look is the default; end dates replace time left when chosen", () => {
const at = "2026-10-16T12:00:00.000Z",
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
const page = () =>
render(
<TimelinePage
state={s}
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
now={Date.parse(at)}
onAction={async () => ({ ok: true })}
onOpen={() => {}}
/>,
);
expect(timelineLook(s.prefs)).toBe("new");
expect(page()).toContain(" left</span>");
s.prefs.timelineLabel = "end";
expect(page()).toContain(">Ends ");
s.prefs.timelineLook = "classic";
expect(timelineLook(s.prefs)).toBe("classic");
expect(page()).toContain('class="classic-timeline"');
expect(page()).not.toContain('class="timeline-board"');
});
test("the focus bar setting defaults to both views and can hide either", () => {
expect(focusBarShown({}, "soon")).toBe(true);
expect(focusBarShown({}, "timeline")).toBe(true);
const prefs = { focusBar: { checklist: true, timeline: false } };
expect(focusBarShown(prefs, "soon")).toBe(true);
expect(focusBarShown(prefs, "timeline")).toBe(false);
expect(focusBarShown({ focusBar: "junk" }, "timeline")).toBe(true);
});
test("both looks draw the shared pill header with the focus bar beneath it", () => {
const at = "2026-10-16T12:00:00.000Z",
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
for (const look of ["new", "classic"]) {
s.prefs.timelineLook = look;
const html = render(
<TimelinePage
state={s}
focusBar={<p className="focus-marker" />}
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
now={Date.parse(at)}
onAction={async () => ({ ok: true })}
onOpen={() => {}}
/>,
);
expect(html).toContain("Show routines in own section");
expect(html).toContain("Show end dates");
expect(html).not.toContain("New look");
expect(html.indexOf("focus-marker")).toBeGreaterThan(
html.indexOf("Jump to today"),
);
}
});
test("the board opens back to the running starts, with now a quarter to half across", () => {
// Starts far back: capped so now sits halfway.
expect(openingLeft(1000, 0, 800)).toBe(600);
// Starts just before now: now still a quarter across.
expect(openingLeft(1000, 990, 800)).toBe(800);
// In between: opens just before the start.
expect(openingLeft(1000, 700, 800)).toBe(672);
});
+79
View File
@@ -0,0 +1,79 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup as render } from "react-dom/server";
import { UpdatesPanel } from "../src/client/components/redesign/UpdatesPanel.tsx";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
const at = "2026-10-16T12:00:00.000Z";
test("codes and source age have distinct compact update rows, with no panel when empty", () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:test"),
feed = {
schemaVersion: 1 as const,
generatedAt: at,
events: [],
sources: [],
};
const common = {
state: s,
feed,
now: Date.parse(at),
onSources: () => {},
onAction: async () => ({ ok: true as const }),
};
expect(render(<UpdatesPanel {...common} />)).toBe("");
const html = render(
<UpdatesPanel
{...common}
codes={{
schemaVersion: 1,
sources: [],
codes: [
{
id: "code",
game: "genshin",
code: "TEST",
reward: null,
expiresAt: null,
firstObservedAt: at,
sourceUrl: "https://example.com",
},
],
}}
/>,
);
expect(html).toContain("New redeem codes");
expect(html).not.toContain("New since you last looked");
expect(html).not.toContain("Older schedules");
});
test("the source-age notice can be hidden for one, three or seven days", async () => {
const s = migrateLegacy({}, ["genshin"], at, () => "account:test");
const feed = {
schemaVersion: 1 as const,
generatedAt: at,
events: [],
sources: [
{
sourceId: "genshin-events",
game: "genshin" as const,
url: "https://example.com",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
eventCount: 0,
parsedCount: null,
statesNoEvents: false,
},
],
};
const html = render(
<UpdatesPanel
state={s}
feed={feed}
now={Date.parse(at)}
onSources={() => {}}
onAction={async () => ({ ok: true as const })}
/>,
);
expect(html).toContain("Event data may be out of date");
expect(html).toContain('aria-label="Hide this notice"');
for (const label of [">1 day<", ">3 days<", ">7 days<"])
expect(html).toContain(label);
});
+55
View File
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { parseRoute, routeHash } from "../src/client/state/route.ts";
test("pages round-trip through the address bar", () => {
for (const route of [
{ page: "soon" },
{ page: "timeline" },
{ page: "routines" },
{ page: "archive" },
{ page: "settings", settings: "changelog" },
{ page: "settings", settings: "games", game: "genshin" },
] as const)
expect(parseRoute(routeHash(route))).toEqual(route);
expect(routeHash({ page: "soon" })).toBe("#checklist");
expect(routeHash({ page: "settings", settings: "about" })).toBe(
"#settings/about",
);
});
test("an unknown or empty hash is no route, so the default view applies", () => {
for (const hash of [
"",
"#",
"#nowhere",
"#settings/nope",
"#settings/games/",
])
expect(parseRoute(hash)).toBeNull();
// A settings hash without a section opens the default section.
expect(parseRoute("#settings")).toEqual({
page: "settings",
settings: "appearance",
});
});
test("the old Default view address opens Appearance, where it moved", () => {
expect(parseRoute("#settings/general")).toEqual({
page: "settings",
settings: "appearance",
});
});
test("the old Lists address opens Data & sources, where its options moved", () => {
expect(parseRoute("#settings/lists")).toEqual({
page: "settings",
settings: "sources",
});
});
test("the games address without a game opens Appearance, where game order lives", () => {
expect(parseRoute("#settings/games")).toEqual({
page: "settings",
settings: "appearance",
});
});
+69
View File
@@ -0,0 +1,69 @@
import { expect, test } from "bun:test";
import { periodOf } from "../src/shared/period.ts";
import { TaskSchema, taskCaption } from "../src/shared/task.ts";
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
import { routineRows } from "../src/client/state/reader/routines.ts";
import { readerDayKey } from "../src/shared/readerClock.ts";
const base = {
id: "mytask:abcdef1234",
game: "genshin",
title: "Log in",
measure: { kind: "days", required: 1 },
at: "2026-10-16T00:00:00.000Z",
updatedAt: "2026-10-16T00:00:00.000Z",
} as const;
test("a daily period is the one game-day", () => {
expect(periodOf({ unit: "day" }, "2026-10-17")).toEqual({
start: "2026-10-17",
days: ["2026-10-17"],
});
expect(taskCaption({ period: { unit: "day" }, measure: base.measure })).toBe(
"every day · tick it off",
);
});
test("own routines keep a reset hour, a note and a web link, and nothing else", () => {
const ok = TaskSchema.safeParse({
...base,
period: { unit: "day" },
resetHour: 20,
note: "Bring a healer",
link: "https://example.com/guide",
});
expect(ok.success).toBe(true);
for (const bad of [
{ link: "javascript:alert(1)" },
{ link: "ftp://example.com" },
{ resetHour: 24 },
{ resetHour: 1.5 },
])
expect(
TaskSchema.safeParse({ ...base, period: { unit: "day" }, ...bad })
.success,
).toBe(false);
// Older records, without the new fields, still parse.
expect(
TaskSchema.safeParse({ ...base, period: { unit: "week", startsOn: 1 } })
.success,
).toBe(true);
});
test("a custom reset hour moves only that routine's day boundary", () => {
const at = "2026-10-16T12:00:00.000Z";
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
s.tasks[base.id] = { ...base, period: { unit: "day" }, resetHour: 20 };
// 10:00 UTC on the 16th is 11:00 in Europe's server time (UTC+1): the
// game's 04:00 reset has opened the 16th, but a 20:00 routine's day labelled
// the 16th has not begun yet.
const now = Date.parse("2026-10-16T10:00:00.000Z");
const rows = routineRows(s, "account:one", now);
const own = rows.find((r) => r.id === base.id)!;
const daily = rows.find((r) => r.id === "dailies:genshin")!;
expect(own.cadence).toBe("day");
expect(own.clock.resetHour).toBe(20);
expect(own.clock.revision).toBe(daily.clock.revision);
expect(readerDayKey(now, daily.clock)).toBe("2026-10-16");
expect(readerDayKey(now, own.clock)).toBe("2026-10-15");
});