diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1f8a83a..dbd6a8d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -631,6 +631,13 @@ legend on the same baseline as "Next: …". (interface, theme, scheme, urgency colours, default view, game order, timeline design, focus bar, checklist group), Your own events and games, Backup (tiers plus a manual "Your backups" list), and per-game Setup / Codes / About. +- **Redeem codes (2026-10-18, owner).** A game's Codes tab lists the codes still to use first. A + code redeemed on every server the reader plays, or past its stated expiry, is greyed and struck + through in a "Redeemed or expired" section below, with its Redeemed box still there to undo. A + date-only expiry counts as passed only once that date has ended everywhere (12:00 UTC the next + day). Settings → Your games marks a followed game with "N new codes" until its Codes tab is opened + (`codeSeen`); codes already expired or redeemed are not counted. Games the reader does not follow + get no marker. - **The tour (2026-10-18, owner).** Every reader takes it once before using the app: new readers straight after setup, returning readers on their first visit, and readers who saw the earlier tour once more. Routines → Checklist → Pick a layout → Timeline → Redeem codes → Archive → Settings. diff --git a/src/client/ReaderApp.tsx b/src/client/ReaderApp.tsx index 6ca1e97..04bec7d 100644 --- a/src/client/ReaderApp.tsx +++ b/src/client/ReaderApp.tsx @@ -993,6 +993,7 @@ function ReaderContent({ {page === "settings" && ( { diff --git a/src/client/components/redesign/CodesPage.tsx b/src/client/components/redesign/CodesPage.tsx index 40def42..b832423 100644 --- a/src/client/components/redesign/CodesPage.tsx +++ b/src/client/components/redesign/CodesPage.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { freshness } from "../../../shared/feed.ts"; -import type { CodesFeed } from "../../../shared/codes.ts"; +import type { CodesFeed, RedeemCode } from "../../../shared/codes.ts"; import { REDEEM } from "../../../shared/redeem.ts"; import type { GameId } from "../../../shared/schema.ts"; import type { ReaderState } from "../../state/reader/schema.ts"; -import { newCodes } from "../../state/reader/codes.ts"; +import { codeExpired, codeUsed, newCodes } from "../../state/reader/codes.ts"; import { actionMeta } from "../../state/reader/actions.ts"; import { formatInZone } from "../../state/displayTime.ts"; import type { ActionHandler } from "./GameSettings.tsx"; @@ -24,6 +24,18 @@ export function CodesPage({ (a) => a.game === game && !a.retiredAt, ); const codes = feed?.codes.filter((c) => c.game === game) ?? []; + // Redeemed on every server, or past its stated expiry: greyed, below the + // codes still to use. + const now = Date.now(); + const done = (c: RedeemCode) => + codeExpired(c, now) || + codeUsed( + c, + accounts.map((a) => a.id), + state.codeRedeemed, + ); + const open = codes.filter((c) => !done(c)), + closed = codes.filter(done); const redeem = REDEEM[game as GameId]; const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : []; useEffect(() => { @@ -36,6 +48,64 @@ export function CodesPage({ ...actionMeta(), }); }, [game, feed, onAction]); + const row = (c: RedeemCode) => ( +
+
+ {c.code} + + {c.reward &&

{c.reward}

} + + {codeExpired(c, now) + ? "Expired" + : c.expiresAt + ? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}` + : c.expiresOn + ? `Expires on ${c.expiresOn} (time not stated)` + : "Expiry not announced"}{" "} + ·{" "} + + Source + + +
+
+ {accounts.map((a) => ( + + ))} +
+
+ ); return (

Redeem codes

@@ -77,62 +147,19 @@ export function CodesPage({ ) : !codes.length ? (

No published codes are available for this game.

) : ( - codes.map((c) => ( -
-
- {c.code} - - {c.reward &&

{c.reward}

} - - {c.expiresAt - ? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}` - : c.expiresOn - ? `Expires on ${c.expiresOn} (time not stated)` - : "Expiry not announced"}{" "} - ·{" "} - - Source - - -
-
- {accounts.map((a) => ( - - ))} -
-
- )) + <> + {open.length ? ( + open.map(row) + ) : ( +

Every code here is redeemed or expired.

+ )} + {closed.length > 0 && ( +
+

Redeemed or expired

+ {closed.map(row)} +
+ )} + )}
); diff --git a/src/client/components/redesign/SettingsLayout.tsx b/src/client/components/redesign/SettingsLayout.tsx index f16a060..bc2463a 100644 --- a/src/client/components/redesign/SettingsLayout.tsx +++ b/src/client/components/redesign/SettingsLayout.tsx @@ -2,6 +2,8 @@ import type { ReaderState } from "../../state/reader/schema.ts"; import { useGameMeta } from "../../state/gameMeta.tsx"; import { orderGames } from "../../state/gameOrder.ts"; import { useState, type ReactNode } from "react"; +import type { CodesFeed } from "../../../shared/codes.ts"; +import { unseenUsableCodes } from "../../state/reader/codes.ts"; export type SettingsPage = | "own" | "games" @@ -27,8 +29,11 @@ export function SettingsLayout({ state, game, onGame, + codes, }: { state?: ReaderState; + /** Marks a followed game with codes not yet seen on its Codes tab. */ + codes?: CodesFeed | null; game?: string | undefined; onGame?: (game: string) => void; page: SettingsPage; @@ -94,6 +99,18 @@ export function SettingsLayout({ .filter(([key]) => accounts.some((a) => a.intent[key])) .map(([, label]) => label) .join(", ") || "Nothing tracked"; + // Only games the reader follows: codes for the others are not news. + const fresh = + codes && !isHidden(g) + ? unseenUsableCodes( + codes, + state!.codeSeen[g] ?? [], + g, + accounts.map((a) => a.id), + state!.codeRedeemed, + Date.now(), + ).length + : 0; return ( ); diff --git a/src/client/redesign.css b/src/client/redesign.css index 3b23027..444dbeb 100644 --- a/src/client/redesign.css +++ b/src/client/redesign.css @@ -4206,6 +4206,42 @@ dialog.archive-detail { padding: 6px 0 0; color: var(--color-near); } +/* A game in the Settings list with codes not yet seen on its Codes tab. */ +.settings-row .settings-new { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + margin-left: auto; + color: var(--color-near); + font-size: 11px; + white-space: nowrap; +} +.settings-row .settings-new::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} +.codes-used { + margin-top: 28px; +} +.codes-used > h3 { + margin: 0 0 6px; + color: var(--color-muted); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.code-row.used { + opacity: 0.55; +} +.code-row.used code { + text-decoration: line-through; + text-decoration-color: var(--color-muted); +} .game-settings .new-code { margin-left: 6px; font-size: 11px; diff --git a/src/client/state/reader/codes.ts b/src/client/state/reader/codes.ts index 6cc6987..54a96e8 100644 --- a/src/client/state/reader/codes.ts +++ b/src/client/state/reader/codes.ts @@ -7,3 +7,42 @@ export function newCodes( const known = new Set(seen); return feed.codes.filter((c) => c.game === game && !known.has(c.id)); } +/** + * Whether a code's stated expiry has passed. A date with no time ("Expires on + * 2026-10-20") counts as passed only once that date has ended everywhere, at + * 12:00 UTC the next day, so a code is never shown expired while it may + * still redeem somewhere. + */ +export function codeExpired(code: RedeemCode, now: number): boolean { + if (code.expiresAt) return Date.parse(code.expiresAt) <= now; + if (code.expiresOn) + return Date.parse(`${code.expiresOn}T00:00:00Z`) + 36 * 3600_000 <= now; + return false; +} +/** Redeemed on every server the reader plays the game on. */ +export function codeUsed( + code: RedeemCode, + accountIds: readonly string[], + redeemed: Record | undefined>, +): boolean { + return ( + accountIds.length > 0 && + accountIds.every((id) => Boolean(redeemed[id]?.[code.id])) + ); +} +/** + * Codes the reader has not opened the game's Codes tab to see, and could + * still use: the Settings games list marks a game with any. + */ +export function unseenUsableCodes( + feed: CodesFeed, + seen: readonly string[], + game: string, + accountIds: readonly string[], + redeemed: Record | undefined>, + now: number, +): RedeemCode[] { + return newCodes(feed, seen, game).filter( + (c) => !codeExpired(c, now) && !codeUsed(c, accountIds, redeemed), + ); +} diff --git a/test/reader-codes-state.test.ts b/test/reader-codes-state.test.ts new file mode 100644 index 0000000..28a2414 --- /dev/null +++ b/test/reader-codes-state.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import type { CodesFeed, RedeemCode } from "../src/shared/codes.ts"; +import { + codeExpired, + codeUsed, + unseenUsableCodes, +} from "../src/client/state/reader/codes.ts"; + +const code = (id: string, over: Partial = {}): RedeemCode => ({ + id, + game: "genshin", + code: id.toUpperCase(), + reward: null, + expiresAt: null, + expiresOn: null, + firstObservedAt: "2026-10-01T00:00:00.000Z", + sourceUrl: "https://example.com/codes", + ...over, +}); +const now = Date.parse("2026-10-18T12:00:00.000Z"); + +test("a code expires at its stated instant", () => { + expect( + codeExpired(code("a", { expiresAt: "2026-10-18T11:59:59.000Z" }), now), + ).toBe(true); + expect( + codeExpired(code("a", { expiresAt: "2026-10-18T12:00:01.000Z" }), now), + ).toBe(false); + expect(codeExpired(code("a"), now)).toBe(false); +}); + +test("a date-only expiry passes only once the date has ended everywhere", () => { + // 17 Oct has ended everywhere by 18 Oct 12:00 UTC; 18 Oct has not. + expect(codeExpired(code("a", { expiresOn: "2026-10-17" }), now)).toBe(true); + expect(codeExpired(code("a", { expiresOn: "2026-10-18" }), now)).toBe(false); + expect(codeExpired(code("a", { expiresOn: "2026-10-17" }), now - 1)).toBe( + false, + ); +}); + +test("a code is used once it is redeemed on every server played", () => { + const redeemed = { "account:eu": { a: "2026-10-18T00:00:00.000Z" } }; + expect(codeUsed(code("a"), ["account:eu"], redeemed)).toBe(true); + expect(codeUsed(code("a"), ["account:eu", "account:na"], redeemed)).toBe( + false, + ); + expect(codeUsed(code("a"), [], redeemed)).toBe(false); +}); + +test("the Settings marker counts unseen codes that can still be used", () => { + const feed: CodesFeed = { + schemaVersion: 1, + sources: [], + codes: [ + code("seen"), + code("fresh"), + code("expired", { expiresAt: "2026-10-01T00:00:00.000Z" }), + code("used"), + code("other", { game: "hsr" }), + ], + }; + const ids = unseenUsableCodes( + feed, + ["seen"], + "genshin", + ["account:eu"], + { "account:eu": { used: "2026-10-18T00:00:00.000Z" } }, + now, + ).map((c) => c.id); + expect(ids).toEqual(["fresh"]); +});