feat(codes): redeemed and expired codes move below, and new codes are marked

A game's Codes tab lists the codes still to use first; a code redeemed on
every server played, or past its stated expiry, is greyed in a "Redeemed or
expired" section below. A date-only expiry passes once that date has ended
everywhere. Settings → Your games marks a followed game with its count of
unseen, still usable codes until its Codes tab is opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-18 05:15:36 +02:00
1 parent 3c81b9b0f1
commit 3b29010e7c
7 files changed
+261 -58

No files matched your search

+7
View File
@@ -631,6 +631,13 @@ legend on the same baseline as "Next: …".
(interface, theme, scheme, urgency colours, default view, game order, timeline design, focus bar, (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 checklist group), Your own events and games, Backup (tiers plus a manual "Your backups" list), and
per-game Setup / Codes / About. 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 - **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 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. once more. Routines → Checklist → Pick a layout → Timeline → Redeem codes → Archive → Settings.
+1
View File
@@ -993,6 +993,7 @@ function ReaderContent({
{page === "settings" && ( {page === "settings" && (
<SettingsLayout <SettingsLayout
state={state} state={state}
codes={codes}
game={codesGame} game={codesGame}
page={settingsPage} page={settingsPage}
onPage={(p) => { onPage={(p) => {
+85 -58
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { freshness } from "../../../shared/feed.ts"; 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 { REDEEM } from "../../../shared/redeem.ts";
import type { GameId } from "../../../shared/schema.ts"; import type { GameId } from "../../../shared/schema.ts";
import type { ReaderState } from "../../state/reader/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 { actionMeta } from "../../state/reader/actions.ts";
import { formatInZone } from "../../state/displayTime.ts"; import { formatInZone } from "../../state/displayTime.ts";
import type { ActionHandler } from "./GameSettings.tsx"; import type { ActionHandler } from "./GameSettings.tsx";
@@ -24,6 +24,18 @@ export function CodesPage({
(a) => a.game === game && !a.retiredAt, (a) => a.game === game && !a.retiredAt,
); );
const codes = feed?.codes.filter((c) => c.game === game) ?? []; 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 redeem = REDEEM[game as GameId];
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : []; const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
useEffect(() => { useEffect(() => {
@@ -36,6 +48,64 @@ export function CodesPage({
...actionMeta(), ...actionMeta(),
}); });
}, [game, feed, onAction]); }, [game, feed, onAction]);
const row = (c: RedeemCode) => (
<article className={done(c) ? "code-row used" : "code-row"} key={c.id}>
<div>
<code>{c.code}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(c.code);
setMessage(`Copied ${c.code}.`);
} catch {
setMessage(
"Could not copy automatically. Select the code to copy it.",
);
}
}}
>
Copy
</button>
{c.reward && <p>{c.reward}</p>}
<small>
{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"}{" "}
·{" "}
<a href={c.sourceUrl} target="_blank" rel="noreferrer">
Source
</a>
</small>
</div>
<div>
{accounts.map((a) => (
<label key={a.id}>
<input
type="checkbox"
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
onChange={(e) =>
void onAction({
type: "redeem",
accountId: a.id,
codeId: c.id,
redeemed: e.target.checked,
...actionMeta(),
})
}
/>
Redeemed
{/* The server only when there is more than one to tell apart. */}
{accounts.length > 1 && ` · ${a.region}`}
</label>
))}
</div>
</article>
);
return ( return (
<section className="codes-page"> <section className="codes-page">
<h2>Redeem codes</h2> <h2>Redeem codes</h2>
@@ -77,62 +147,19 @@ export function CodesPage({
) : !codes.length ? ( ) : !codes.length ? (
<p>No published codes are available for this game.</p> <p>No published codes are available for this game.</p>
) : ( ) : (
codes.map((c) => ( <>
<article className="code-row" key={c.id}> {open.length ? (
<div> open.map(row)
<code>{c.code}</code> ) : (
<button <p>Every code here is redeemed or expired.</p>
type="button" )}
onClick={async () => { {closed.length > 0 && (
try { <section className="codes-used">
await navigator.clipboard.writeText(c.code); <h3>Redeemed or expired</h3>
setMessage(`Copied ${c.code}.`); {closed.map(row)}
} catch { </section>
setMessage( )}
"Could not copy automatically. Select the code to copy it.", </>
);
}
}}
>
Copy
</button>
{c.reward && <p>{c.reward}</p>}
<small>
{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"}{" "}
·{" "}
<a href={c.sourceUrl} target="_blank" rel="noreferrer">
Source
</a>
</small>
</div>
<div>
{accounts.map((a) => (
<label key={a.id}>
<input
type="checkbox"
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
onChange={(e) =>
void onAction({
type: "redeem",
accountId: a.id,
codeId: c.id,
redeemed: e.target.checked,
...actionMeta(),
})
}
/>
Redeemed
{/* The server only when there is more than one to tell apart. */}
{accounts.length > 1 && ` · ${a.region}`}
</label>
))}
</div>
</article>
))
)} )}
</section> </section>
); );
@@ -2,6 +2,8 @@ import type { ReaderState } from "../../state/reader/schema.ts";
import { useGameMeta } from "../../state/gameMeta.tsx"; import { useGameMeta } from "../../state/gameMeta.tsx";
import { orderGames } from "../../state/gameOrder.ts"; import { orderGames } from "../../state/gameOrder.ts";
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import type { CodesFeed } from "../../../shared/codes.ts";
import { unseenUsableCodes } from "../../state/reader/codes.ts";
export type SettingsPage = export type SettingsPage =
| "own" | "own"
| "games" | "games"
@@ -27,8 +29,11 @@ export function SettingsLayout({
state, state,
game, game,
onGame, onGame,
codes,
}: { }: {
state?: ReaderState; state?: ReaderState;
/** Marks a followed game with codes not yet seen on its Codes tab. */
codes?: CodesFeed | null;
game?: string | undefined; game?: string | undefined;
onGame?: (game: string) => void; onGame?: (game: string) => void;
page: SettingsPage; page: SettingsPage;
@@ -94,6 +99,18 @@ export function SettingsLayout({
.filter(([key]) => accounts.some((a) => a.intent[key])) .filter(([key]) => accounts.some((a) => a.intent[key]))
.map(([, label]) => label) .map(([, label]) => label)
.join(", ") || "Nothing tracked"; .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 ( return (
<button <button
key={g} key={g}
@@ -113,6 +130,11 @@ export function SettingsLayout({
: ""} : ""}
</small> </small>
</span> </span>
{fresh > 0 && (
<span className="settings-new">
{fresh} new {fresh === 1 ? "code" : "codes"}
</span>
)}
{chevron} {chevron}
</button> </button>
); );
+36
View File
@@ -4206,6 +4206,42 @@ dialog.archive-detail {
padding: 6px 0 0; padding: 6px 0 0;
color: var(--color-near); 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 { .game-settings .new-code {
margin-left: 6px; margin-left: 6px;
font-size: 11px; font-size: 11px;
+39
View File
@@ -7,3 +7,42 @@ export function newCodes(
const known = new Set(seen); const known = new Set(seen);
return feed.codes.filter((c) => c.game === game && !known.has(c.id)); 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<string, Record<string, unknown> | 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<string, Record<string, unknown> | undefined>,
now: number,
): RedeemCode[] {
return newCodes(feed, seen, game).filter(
(c) => !codeExpired(c, now) && !codeUsed(c, accountIds, redeemed),
);
}
+71
View File
@@ -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> = {}): 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"]);
});