fix: derive the colophon's credits from the feed

It still thanked only Game8 and hardcoded the studio list, so wiki.gg went
uncredited and unmentioned in the disclaimer after being added as a source.

Sources and studios are now read from the feed and the game registry, so
adding either credits the right people automatically. A hardcoded credit goes
stale the moment someone adds a source, and out of date is the one thing a
credit must not be.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:43:47 +02:00
co-authored by Claude Opus 5
parent 6fad016f66
commit 25c32dbe62
2 changed files with 65 additions and 28 deletions
+50 -15
View File
@@ -3,12 +3,28 @@ import type { SourceHealth } from "../../shared/feed.ts";
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker";
/** Display name and homepage for a source host. */
const SITES: Record<string, { name: string; url: string }> = {
"game8.co": { name: "Game8", url: "https://game8.co" },
"endfield.wiki.gg": { name: "wiki.gg", url: "https://wiki.gg" },
};
function siteFor(url: string): { name: string; url: string } {
try {
const host = new URL(url).host;
return SITES[host] ?? { name: host, url: `https://${host}` };
} catch {
return { name: url, url };
}
}
/**
* Credit, disclaimer, and where the code lives.
*
* This sits at the foot of the page rather than behind an "About" link: the
* people whose work this depends on should be visible on the same screen as
* the data, not one navigation step away from it.
* Everything here is derived from the feed rather than written down, so adding
* a source or a game credits the right people automatically. A hardcoded list
* silently goes stale the moment someone adds the seventh source, and the one
* thing a credit must not be is out of date.
*/
export function Colophon({
sources,
@@ -17,7 +33,15 @@ export function Colophon({
sources: SourceHealth[];
staleCount: number;
}) {
const games = [...new Set(sources.map((s) => s.game))];
const games = [...new Set(sources.map((s) => s.game))].map(gameMeta);
const studios = [...new Set(games.map((g) => g.studio))];
const sites = [...new Map(sources.map((s) => {
const site = siteFor(s.url);
return [site.name, site] as const;
})).values()];
const named = [...sites.map((s) => s.name), ...studios];
return (
<footer className="border-t border-hairline px-4 pb-12 pt-6 text-xs leading-relaxed text-faint">
@@ -36,23 +60,34 @@ export function Colophon({
<div className="mt-5">
<p className="eyebrow">With thanks to</p>
<p className="mt-1.5">
{sites.map((site, i) => (
<span key={site.name}>
{i > 0 && (i === sites.length - 1 ? " and " : ", ")}
<a
href="https://game8.co"
href={site.url}
target="_blank"
rel="noreferrer noopener"
className="text-muted underline decoration-hairline underline-offset-2 transition-colors duration-150 hover:text-ink hover:decoration-near"
>
Game8
{site.name}
</a>
</span>
))}
{", whose editors compile and maintain the event calendars this reads from. The schedules are their work; this page only rearranges them."}
</p>
<p className="mt-2">
And to HoYoverse, Kuro Games, Hypergryph and Hotta Studio, who make the
games worth keeping track of {" "}
{games.map((g, i) => (
<span key={g}>
And to{" "}
{studios.map((studio, i) => (
<span key={studio}>
{i > 0 && (i === studios.length - 1 ? " and " : ", ")}
{studio}
</span>
))}
, who make the games worth keeping track of {" "}
{games.map((game, i) => (
<span key={game.id}>
{i > 0 && ", "}
<span style={{ color: gameMeta(g).hue }}>{gameMeta(g).name}</span>
<span style={{ color: game.hue }}>{game.name}</span>
</span>
))}
{"."}
@@ -62,10 +97,10 @@ export function Colophon({
<div className="mt-5 border-t border-hairline pt-4">
<p>
<strong className="font-semibold text-muted">Not affiliated</strong>{" "}
with Game8, HoYoverse, Kuro Games, Hypergryph, Hotta Studio, or any
other publisher or source named here. This is an unofficial fan-made
tool, not endorsed by or connected to any of them. All game names,
event names and trademarks belong to their respective owners.
with {named.join(", ")}, or any other publisher or source named here.
This is an unofficial fan-made tool, not endorsed by or connected to
any of them. All game names, event names and trademarks belong to their
respective owners.
</p>
<p className="mt-2">
Event dates can be wrong or go out of date. Treat the source page as
+9 -7
View File
@@ -11,16 +11,18 @@ export interface GameMeta {
* "whose event is this?" and "how long have I got?" at the same time.
*/
hue: string;
/** Who makes it. Credited in the colophon, derived rather than hardcoded. */
studio: string;
}
export const GAMES: Record<GameId, GameMeta> = {
genshin: { id: "genshin", name: "Genshin Impact", short: "Genshin", hue: "#4EA8DE" },
hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" },
zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" },
wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" },
arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" },
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" },
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" },
genshin: { id: "genshin", name: "Genshin Impact", short: "Genshin", hue: "#4EA8DE" , studio: "HoYoverse" },
hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" , studio: "HoYoverse" },
zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse" },
wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" , studio: "Kuro Games" },
arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" , studio: "Hypergryph" },
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph" },
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio" },
};
export const GAME_LIST: GameMeta[] = Object.values(GAMES);