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:
co-authored by
Claude Opus 5.5
parent
3c81b9b0f1
commit
3b29010e7c
@@ -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.
|
||||
|
||||
@@ -993,6 +993,7 @@ function ReaderContent({
|
||||
{page === "settings" && (
|
||||
<SettingsLayout
|
||||
state={state}
|
||||
codes={codes}
|
||||
game={codesGame}
|
||||
page={settingsPage}
|
||||
onPage={(p) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user