Files
gacha-event-tracker/src/client/components/redesign/CodesPage.tsx
T
Lucas WintherandClaude Opus 5.5 54d13d7472 feat: shine a code redeemed or an event completed on its way out
A code redeemed on its last server sweeps in the game's colour and greys
in place for 0.8 s, then lands in Redeemed or expired. A completed event
greys and shines while its Moved to Archive notice holds, now 0.9 s
rather than 1.5 s, which read as unresponsive; an ignored one only greys.

Settings → Appearance gains Motion & celebrations as three What to track
cards: Reduce motion (stops every animation and the fireworks, adding to
the device's own setting), Fireworks, and Shine. Reduce motion locks the
other two off without forgetting them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:26:50 +02:00

254 lines
8.1 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { freshness } from "../../../shared/feed.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 { 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";
import { shineOn } from "../Fireworks.tsx";
import { motionReduced } from "../../state/appearance.ts";
// A redeemed code shines where it was ticked, then lands among the redeemed.
const SHINE_MS = 800,
LAND_MS = 360;
/**
* The open list and the redeemed-or-expired list. A code still shining from
* the tick that redeemed it holds its place in the open list, not greyed yet.
*/
export function codeLanes<C extends { id: string }>(
codes: readonly C[],
done: (c: C) => boolean,
shining: ReadonlySet<string>,
) {
const used = (c: C) => done(c) && !shining.has(c.id);
return {
open: codes.filter((c) => !used(c)),
closed: codes.filter(used),
used,
};
}
export function CodesPage({
game,
feed,
state,
onAction,
}: {
game: string;
feed: CodesFeed | null;
state: ReaderState;
onAction: ActionHandler;
}) {
const [message, setMessage] = useState("");
const accounts = Object.values(state.accounts).filter(
(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,
);
// Only the reader's own ticks animate: a code already redeemed, or one that
// expires by itself, never plays on arrival.
const [shining, setShining] = useState<ReadonlySet<string>>(new Set());
const [landing, setLanding] = useState<ReadonlySet<string>>(new Set());
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const settle = (id: string) => {
clearTimeout(timers.current.get(id));
timers.current.delete(id);
};
const without = (id: string) => (set: ReadonlySet<string>) => {
const next = new Set(set);
next.delete(id);
return next;
};
const shine = (id: string) => {
settle(id);
setShining((set) => new Set(set).add(id));
timers.current.set(
id,
setTimeout(() => {
setShining(without(id));
setLanding((set) => new Set(set).add(id));
timers.current.set(
id,
setTimeout(() => {
timers.current.delete(id);
setLanding(without(id));
}, LAND_MS),
);
}, SHINE_MS),
);
};
const { open, closed, used } = codeLanes(codes, done, shining);
const redeem = REDEEM[game as GameId];
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
useEffect(() => {
if (unseen.length)
void onAction({
type: "seen",
kind: "codeSeen",
id: game,
ids: unseen.map((c) => c.id),
...actionMeta(),
});
}, [game, feed, onAction]);
const row = (c: RedeemCode) => (
<article
className={used(c) ? "code-row used" : "code-row"}
key={c.id}
data-shine={shining.has(c.id) || undefined}
data-landing={landing.has(c.id) || undefined}
>
<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.confirmedDead
? `Confirmed expired in game, ${c.confirmedDead}.`
: 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} data-fresh={fresh.has(a.id + c.id) || undefined}>
<input
type="checkbox"
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
onChange={(e) => {
const redeemed = e.target.checked;
setFresh((set) =>
redeemed
? new Set(set).add(a.id + c.id)
: without(a.id + c.id)(set),
);
if (!redeemed) {
settle(c.id);
setShining(without(c.id));
setLanding(without(c.id));
} else if (
!codeExpired(c, now) &&
// Without the shine, or holding still, it moves straight down.
shineOn(state.prefs) &&
!motionReduced(state.prefs) &&
// This tick is the last server still to redeem it.
accounts.every(
(o) => o.id === a.id || state.codeRedeemed[o.id]?.[c.id],
)
)
shine(c.id);
void onAction({
type: "redeem",
accountId: a.id,
codeId: c.id,
redeemed,
...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>
<p className="help">
New-code notices are separate from redemption. Mark codes redeemed for
each account you play.
</p>
{redeem && (
<div className="redeem-where">
{redeem.url && (
<a
className="commit"
href={redeem.url}
target="_blank"
rel="noreferrer noopener"
>
Redeem on the official site ↗
</a>
)}
<p>
<span>{redeem.url ? "Or in game:" : "Redeem in game:"}</span>{" "}
{redeem.inGame}
</p>
</div>
)}
<p role="status">{message}</p>
{feed &&
freshness(
feed.sources.filter((s) => s.game === game),
Date.now(),
).stale.length > 0 && (
<p className="help">
Code data may be out of date. Check the linked source before relying
on a code's availability.
</p>
)}
{!feed ? (
<p>The codes feed is unavailable. Event tracking is still available.</p>
) : !codes.length ? (
<p>No published codes are available for this game.</p>
) : (
<>
{open.length ? (
open.map(row)
) : (
<p
className="codes-clear"
data-fresh={landing.size > 0 || undefined}
>
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>
);
}