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:
1 parent
ddd9673f86
commit
50c6e91b00
284 files changed
+45100
-1628
No files matched your search
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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"];
|
||||
};
|
||||
Reference in new issue
Block a user