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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-18 01:55:16 +02:00
1 parent ddd9673f86
commit 50c6e91b00
284 files changed
+45100 -1628

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
import type { CodesFeed, RedeemCode } from "../../shared/codes.ts";
export function buildCodesFeed(
previous: CodesFeed | null,
parsed: readonly Omit<RedeemCode, "firstObservedAt">[],
at: string,
): CodesFeed {
const first = new Map(previous?.codes.map((c) => [c.id, c.firstObservedAt]));
return {
schemaVersion: 1,
codes: parsed.map((c) => ({
...c,
firstObservedAt: first.get(c.id) ?? at,
})),
sources: previous?.sources ?? [],
};
}
+91
View File
@@ -0,0 +1,91 @@
import { text, decodeEntities } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
import type { RedeemCode } from "../../shared/codes.ts";
function parseLatest(
html: string,
ctx: ParseContext,
): Omit<RedeemCode, "firstObservedAt">[] {
const heading = /<h[23]\b[^>]*>\s*Latest Redeem Codes\s*<\/h[23]>/i.exec(
html,
);
if (!heading)
throw new Error("The current codes section could not be identified");
const after = html.slice(heading.index + heading[0].length),
end = after.search(/<h[23]\b/i),
section = end < 0 ? after : after.slice(0, end);
const table = /<table\b[^>]*>([\s\S]*?)<\/table>/i.exec(section);
if (
!table ||
!/<th\b[^>]*>\s*Redeem Codes\s*<\/th>/i.test(table[0]) ||
!/<th\b[^>]*>\s*Rewards\s*<\/th>/i.test(table[0])
)
throw new Error("The codes table has changed shape");
const codes = new Map<string, Omit<RedeemCode, "firstObservedAt">>();
for (const tr of table[1]!.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
const cells = [...tr[1]!.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)];
if (!cells.length) continue;
const input =
/<input\b(?=[^>]*\bclass=["'][^"']*a-clipboard__textInput)(?=[^>]*\bvalue=["']([^"']+)["'])[^>]*>/i.exec(
cells[0]![1]!,
);
if (!input) throw new Error("A current-code row has no readable code");
const code = decodeEntities(input[1]!).trim();
if (!code || code.length > 100) throw new Error("Invalid code text");
const reward = cells[1] ? text(cells[1][1]!) : null;
const id = JSON.stringify([ctx.game, code]);
codes.set(id, {
id,
game: ctx.game,
code,
reward: reward || null,
expiresAt: null,
sourceUrl: ctx.sourceUrl,
});
}
return [...codes.values()];
}
export function parseGame8Codes(
html: string,
ctx: ParseContext,
): Omit<RedeemCode, "firstObservedAt">[] {
const codes = new Map(parseLatest(html, ctx).map((c) => [c.id, c]));
const heading = /<h2\b[^>]*>\s*[\d.]+ Special Program Codes\s*<\/h2>/i.exec(
html,
);
if (heading) {
const after = html.slice(heading.index + heading[0].length),
end = after.search(/<h2\b/i),
section = end < 0 ? after : after.slice(0, end);
const expiry =
/cannot be redeemed after\s+([A-Z][a-z]+ \d{1,2}, \d{4})/.exec(
text(section),
);
const expired =
expiry &&
Number.isFinite(Date.parse(expiry[1]! + " 00:00:00 GMT")) &&
Date.parse(ctx.now) >=
Date.parse(expiry[1]! + " 00:00:00 GMT") + 2 * 86400000;
if (!expired) {
for (const table of section.matchAll(
/<table\b[^>]*>[\s\S]*?<\/table>/gi,
)) {
if (!/Global Codes/.test(table[0])) continue;
const transformed =
"<h3>Latest Redeem Codes</h3>" +
table[0].replace("Global Codes", "Redeem Codes");
for (const code of parseLatest(transformed, ctx)) {
if (
expiry &&
Number.isFinite(Date.parse(expiry[1]! + " 00:00:00 GMT"))
)
code.expiresOn = new Date(Date.parse(expiry[1]! + " 00:00:00 GMT"))
.toISOString()
.slice(0, 10);
codes.set(code.id, code);
}
}
}
}
return [...codes.values()];
}
+18
View File
@@ -0,0 +1,18 @@
import { SIX_HOURS_MS, type Adapter } from "../adapters/types.ts";
import { parseGame8Codes } from "./game8.ts";
export const CODE_SOURCES = [
{
id: "genshin-game8-codes",
game: "genshin" as const,
url: "https://game8.co/games/Genshin-Impact/archives/304759",
parserId: "game8-codes",
minIntervalMs: SIX_HOURS_MS,
priority: 0,
parse: parseGame8Codes,
},
];
// Fetch policy only depends on source identity, interval and parse count. Event adapters keep
// their GachaEvent return contract; the scheduler can also count code records without faking events.
export type RefreshSource = Omit<Adapter, "parse"> & {
parse: typeof parseGame8Codes | Adapter["parse"];
};