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:
1 parent
3c81b9b0f1
commit
3b29010e7c
7 files changed
+261
-58
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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