feat: the redesigned Event Clock
The new reader app replaces the old UI: Checklist with a rail (focus pills, Next to expire, today's dailies, pinned routines), Timeline in a new and a classic look, Routines, Archive and a Settings area, in a Structured (default) and a Relaxed interface. It follows the approved Round 6 B and Round 7 mockups, corrected screen by screen with the owner. Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data is migrated on first load, a recovery copy is written first, and the v1 keys are never removed, so a rollback finds them unchanged. Redeem codes for Genshin arrive as their own feed. Also: live's urgency ramp, editable per theme; completion and section-complete fireworks; a returning-user tour; the design mockups, specs, plans, reviews and handoff record. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddd9673f86
commit
50c6e91b00
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user