A public fork of this project worked out several things worth having — a theme toggle, an archive for finished events — and ideas are not code: nothing here carries a licence obligation, so this is thanks rather than a term, and it belongs in the credits rather than in NOTICE with the terms. Credited by GitHub handle and fork URL, not by name. The commits behind that fork carry a third name again, and a handle is the one identity that is verifiable and stable. Hardcoded, unlike the source and studio credits above it — nothing in the feed knows a fork exists, so there is nothing to derive it from. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
import { gameMeta } from "../../shared/games.ts";
|
|
import { freshness, type SourceHealth } from "../../shared/feed.ts";
|
|
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
|
|
|
|
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker";
|
|
|
|
/**
|
|
* Where a reader takes a problem or an idea.
|
|
*
|
|
* `template=` names the file in `.github/ISSUE_TEMPLATE/`, so renaming one of
|
|
* those files breaks these links — GitHub falls back to the template chooser
|
|
* rather than erroring, which is a soft landing but not the intended one.
|
|
*/
|
|
export const BUG_URL = `${REPO_URL}/issues/new?template=bug_report.yml`;
|
|
export const FEATURE_URL = `${REPO_URL}/issues/new?template=feature_request.yml`;
|
|
|
|
/**
|
|
* The bug form, with the footer's own freshness line already filled in.
|
|
*
|
|
* A wrong end date and a stale calendar look identical to a reader, and eight of
|
|
* the sources cannot be fetched from CI at all — so "how old is this page's data"
|
|
* is the first thing anyone triaging a date report has to establish, and the one
|
|
* thing they cannot recover after the fact. Asking the reader to copy it works;
|
|
* carrying it for them works more often.
|
|
*
|
|
* `refreshed` must stay the `id` of the matching field in `bug_report.yml`.
|
|
* GitHub silently drops a parameter that names no field, so a drift here costs
|
|
* the prefill with no error anywhere — `test/issue-templates.test.tsx` pins it.
|
|
*/
|
|
export function bugReportUrl(refreshed: string | null): string {
|
|
if (refreshed === null) return BUG_URL;
|
|
return `${BUG_URL}&refreshed=${encodeURIComponent(refreshed)}`;
|
|
}
|
|
|
|
/** Who built this, and where to find them. */
|
|
export const AUTHOR = {
|
|
name: "Lucas Winther",
|
|
site: "https://lucaswinther.info",
|
|
github: "https://github.com/StereotypicalCat",
|
|
} as const;
|
|
|
|
const LINK =
|
|
"text-muted underline decoration-hairline underline-offset-2 transition-colors duration-150 hover:text-ink hover:decoration-near";
|
|
|
|
/**
|
|
* Forks whose ideas ended up here.
|
|
*
|
|
* Ideas and design, not code — nothing in this list carries a licence
|
|
* obligation, which is why it sits here as thanks rather than in NOTICE with
|
|
* the terms. Handles rather than names: a handle is verifiable and stable, and
|
|
* whoever is behind a fork does not always publish a name to credit.
|
|
*
|
|
* Unlike the source and studio credits above, this one cannot be derived from
|
|
* the feed — nothing in the data knows a fork exists. Hardcoded on purpose;
|
|
* adding another is one line.
|
|
*/
|
|
export const IDEA_CREDITS = [
|
|
{
|
|
handle: "phuduong85",
|
|
url: "https://github.com/phuduong85/gacha-event-tracker",
|
|
},
|
|
] as const;
|
|
|
|
/**
|
|
* How many lagging games to name before summarising the rest.
|
|
*
|
|
* Naming them is the point — a reader can act on a name — but past a handful the
|
|
* list stops being read, and every extra entry repeating the same age crowds out
|
|
* the sentence that matters.
|
|
*/
|
|
const STALE_NAMES = 4;
|
|
|
|
function GitHubMark() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor" aria-hidden>
|
|
<path d="M8 0a8 8 0 0 0-2.53 15.59c.4.07.55-.17.55-.38l-.01-1.49c-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.4 7.4 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48l-.01 2.19c0 .21.15.46.55.38A8 8 0 0 0 8 0Z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** 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.
|
|
*
|
|
* 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,
|
|
now,
|
|
}: {
|
|
sources: SourceHealth[];
|
|
now: number;
|
|
}) {
|
|
const games = [...new Set(sources.map((s) => s.game))].map(gameMeta);
|
|
const studios = [...new Set(games.map((g) => g.studio))];
|
|
const { refreshedAt, stale } = freshness(sources, now);
|
|
|
|
// Built once so the sentence a reader reads and the value the bug form is
|
|
// prefilled with cannot drift apart.
|
|
const ago =
|
|
refreshedAt === null ? null : formatRemaining(now - Date.parse(refreshedAt));
|
|
const refreshedLine =
|
|
refreshedAt === null ? null : `${formatAbsolute(refreshedAt, true)} — ${ago} ago`;
|
|
|
|
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">
|
|
{/* Three columns once there is room: the age of the data, who compiled
|
|
it, and what it is not. Stacked they are a long scroll of small grey
|
|
text that a reader gives up on before reaching the disclaimer, which
|
|
is the one part that has to be read. */}
|
|
<div className="lg:grid lg:grid-cols-3 lg:gap-x-10">
|
|
<div>
|
|
<p>
|
|
Dates are shown in your local time. Every event links to the page it came
|
|
from — check there before the last hours.
|
|
</p>
|
|
|
|
{/*
|
|
Stated on every load, not only when something is wrong. A page that says
|
|
nothing about its own age reads as current, and "how old is this?" is the
|
|
question a reader has to be able to answer before trusting a countdown
|
|
(PRD F7). The date is absolute *and* relative on purpose: the relative
|
|
half is what gets read, the absolute half is what can be checked.
|
|
*/}
|
|
<p className="mt-2">
|
|
<span className="text-muted">Event data last refreshed</span>{" "}
|
|
{refreshedAt === null ? (
|
|
"— no source has been fetched yet."
|
|
) : (
|
|
<>
|
|
<time dateTime={refreshedAt} className="text-muted">
|
|
{formatAbsolute(refreshedAt, true)}
|
|
</time>
|
|
{` — ${ago} ago.`}
|
|
</>
|
|
)}
|
|
</p>
|
|
|
|
{stale.length > 0 && (
|
|
// Named per game rather than counted, because a count is not something a
|
|
// reader can act on: knowing *which* lane is behind tells them which
|
|
// source page to go and check, which is the whole remedy on offer.
|
|
//
|
|
// Except when the answer is "all of them", which is what a refresh that
|
|
// stopped running looks like. Ten names each repeating the same age is
|
|
// less readable than the count this replaced, and the headline above
|
|
// already gives the date — so that case gets a sentence, not a list.
|
|
<p className="mt-2 text-soon">
|
|
{stale.length === games.length ? (
|
|
`Nothing has refreshed in over two days, so any end date here may have moved.`
|
|
) : (
|
|
<>
|
|
{stale.length === 1 ? "This game has" : "These games have"} not
|
|
refreshed in over two days, so some of their end dates may have
|
|
moved:{" "}
|
|
{stale.slice(0, STALE_NAMES).map((s, i, shown) => (
|
|
<span key={s.game}>
|
|
{i > 0 && (i === shown.length - 1 && stale.length <= STALE_NAMES ? " and " : ", ")}
|
|
{gameMeta(s.game).name}
|
|
{s.lastSuccessAt === null
|
|
? " (never)"
|
|
: ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`}
|
|
</span>
|
|
))}
|
|
{stale.length > STALE_NAMES &&
|
|
` and ${stale.length - STALE_NAMES} other game${
|
|
stale.length - STALE_NAMES > 1 ? "s" : ""
|
|
}`}
|
|
{"."}
|
|
</>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-5 lg:mt-0">
|
|
<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={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"
|
|
>
|
|
{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{" "}
|
|
{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: game.hue }}>{game.name}</span>
|
|
</span>
|
|
))}
|
|
{"."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-5 border-t border-hairline pt-4 lg:mt-0 lg:border-t-0 lg:pt-0">
|
|
<p>
|
|
<strong className="font-semibold text-muted">Not affiliated</strong>{" "}
|
|
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
|
|
the authority, not this one.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="mt-6 border-t border-hairline pt-5">
|
|
Built by{" "}
|
|
<a
|
|
href={AUTHOR.site}
|
|
target="_blank"
|
|
rel="noreferrer noopener me"
|
|
className={LINK}
|
|
>
|
|
{AUTHOR.name}
|
|
</a>
|
|
{"."}
|
|
</p>
|
|
|
|
{IDEA_CREDITS.length > 0 && (
|
|
<p className="mt-2">
|
|
Additional ideas and design from{" "}
|
|
{IDEA_CREDITS.map((c, i) => (
|
|
<span key={c.handle}>
|
|
{i > 0 && (i === IDEA_CREDITS.length - 1 ? " and " : ", ")}
|
|
<a
|
|
href={c.url}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className={LINK}
|
|
>
|
|
{c.handle}
|
|
</a>
|
|
</span>
|
|
))}
|
|
{"."}
|
|
</p>
|
|
)}
|
|
|
|
<p className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
|
<a
|
|
href={AUTHOR.github}
|
|
target="_blank"
|
|
rel="noreferrer noopener me"
|
|
className="inline-flex items-center gap-1.5 text-muted transition-colors duration-150 hover:text-ink"
|
|
>
|
|
<GitHubMark />
|
|
@StereotypicalCat
|
|
</a>
|
|
<a
|
|
href={REPO_URL}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className="inline-flex items-center gap-1.5 text-muted transition-colors duration-150 hover:text-ink"
|
|
>
|
|
<GitHubMark />
|
|
Source code
|
|
</a>
|
|
</p>
|
|
|
|
{/*
|
|
Placed under the disclaimer that admits dates can be wrong, because that
|
|
paragraph is where a reader who has just found one is looking. The bug
|
|
link carries the freshness line above it, so a report arrives already
|
|
saying whether the calendar was current when it was wrong.
|
|
*/}
|
|
<p className="mt-2">
|
|
Something wrong, missing, or worth adding?{" "}
|
|
<a
|
|
href={bugReportUrl(refreshedLine)}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className={LINK}
|
|
>
|
|
Report a problem
|
|
</a>{" "}
|
|
or{" "}
|
|
<a
|
|
href={FEATURE_URL}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className={LINK}
|
|
>
|
|
request a feature
|
|
</a>
|
|
{"."}
|
|
</p>
|
|
</footer>
|
|
);
|
|
}
|