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]>
133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
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);
|
|
});
|