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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-18 05:15:36 +02:00
co-authored by Claude Opus 5.5
parent 3c81b9b0f1
commit 3b29010e7c
7 changed files with 261 additions and 58 deletions
+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,
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.
+1
View File
@@ -993,6 +993,7 @@ function ReaderContent({
{page === "settings" && (
<SettingsLayout
state={state}
codes={codes}
game={codesGame}
page={settingsPage}
onPage={(p) => {
+85 -58
View File
@@ -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) => (
<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 (
<section className="codes-page">
<h2>Redeem codes</h2>
@@ -77,62 +147,19 @@ export function CodesPage({
) : !codes.length ? (
<p>No published codes are available for this game.</p>
) : (
codes.map((c) => (
<article className="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>
{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>
))
<>
{open.length ? (
open.map(row)
) : (
<p>Every code here is redeemed or expired.</p>
)}
{closed.length > 0 && (
<section className="codes-used">
<h3>Redeemed or expired</h3>
{closed.map(row)}
</section>
)}
</>
)}
</section>
);
@@ -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 (
<button
key={g}
@@ -113,6 +130,11 @@ export function SettingsLayout({
: ""}
</small>
</span>
{fresh > 0 && (
<span className="settings-new">
{fresh} new {fresh === 1 ? "code" : "codes"}
</span>
)}
{chevron}
</button>
);
+36
View File
@@ -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;
+39
View File
@@ -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<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"]);
});