feat(github): give readers two issue forms and link to them

Nothing a reader marks, types or ticks ever leaves their browser, so there is no
account and no server-side record to look anything up in after a report arrives.
Whatever the form captured is all there will ever be — which is the argument for
structured forms over a blank box, and for the bug form carrying the footer's
"event data last refreshed" line as a prefill. A stale calendar and a genuinely
wrong parse are indistinguishable from outside, eight sources cannot be fetched
from CI at all, and that line is the only thing that separates the two cases.

The feature form states the three constraints up front, because "sync across my
devices" and "have a model read the page" both have settled answers and a reader
deserves them before typing rather than after. It also points source requests at
the assessed-and-declined table: the loudest feedback on the first release was
that the game list is too short, so that is the request that arrives most, and a
wiki that forbids automated access or only archives finished events cannot be
used no matter how often it is asked for.

Blank issues stay enabled. A form that turns someone away loses the report, not
the noise.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 23:19:47 +02:00
co-authored by Claude Opus 5
parent 32a0683824
commit 02dd7b3ed4
7 changed files with 441 additions and 2 deletions
+103
View File
@@ -0,0 +1,103 @@
name: Bug report
description: A wrong date, a missing event, a lost tick, or anything else the app got wrong.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting this.
**If a date looks wrong, please open the event and check its source link first.**
This app does not compile schedules; it rearranges what a wiki published. If the wiki
page says the same thing, the fix belongs there — and it is still worth telling us,
because we may be able to use a better source for that game.
Nothing you have marked, typed or ticked leaves your browser, so there is no account
for us to look up. That means the details below are all we have to go on.
- type: dropdown
id: area
attributes:
label: What part of the app?
options:
- A wrong date or time
- An event is missing
- An event that should not be there
- Dailies, streaks or the checklist
- The list, calendar or sorting
- Events I entered myself
- Offline, installing, or updating
- Export or import
- Something else
validations:
required: true
- type: input
id: event
attributes:
label: Which game and event?
description: The game, and the event title exactly as the app shows it.
placeholder: "Genshin Impact — Mutual Aid in Bloom: Into the Frostlands"
validations:
required: false
- type: textarea
id: what
attributes:
label: What happened, and what did you expect?
description: |
For a date problem, the useful shape is: what this app shows, what the source page
shows, and which one you believe.
placeholder: |
The app says it ends 24 Aug, 09:00. The Game8 page says 31 Aug.
validations:
required: true
- type: input
id: refreshed
attributes:
label: "\"Event data last refreshed\" line"
description: |
Copy it from the bottom of the page. Several sources cannot be fetched automatically,
so a stale calendar is a normal state with its own explanation — this line tells the
two cases apart before anyone goes looking for a parser bug.
placeholder: Event data last refreshed 15 Aug 2026, 04:12 — 2 days ago.
validations:
required: false
- type: input
id: source
attributes:
label: Source page
description: The wiki page the event links to, if the problem involves its data.
placeholder: https://game8.co/games/Genshin-Impact/archives/...
validations:
required: false
- type: input
id: where
attributes:
label: Where are you running it, and in what browser?
placeholder: stereotypicalcat.github.io/gacha-event-tracker — Firefox 141 on Android
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else
description: |
Screenshots help. If you attach an export file, remember it is a record of your own
progress — trim it to the event in question if you would rather not share the rest.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Before you post
options:
- label: I checked the event's source page, or this is not about a date.
required: false
- label: I searched the open issues for the same problem.
required: false
+5
View File
@@ -0,0 +1,5 @@
# Blank issues stay available on purpose. The two forms cover the reports we can
# act on fastest, but a reader who cannot fit what they saw into either of them
# should still be able to say so — a form that turns someone away loses the
# report, not the noise.
blank_issues_enabled: true
@@ -0,0 +1,68 @@
name: Feature request
description: Ask for a game, a source, or something the app should do.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Ideas are welcome. Three things are settled, though, so you know before you type:
- **No accounts, no logins, no server-side records.** Everything you mark stays in your
browser. "Sync across my devices" is answered by exporting a file and importing it on
the other device, not by an account.
- **No LLM reads any page.** Every date comes from deterministic parsers. A source that
cannot be parsed reliably gets no adapter, rather than a model guessing at it.
- **No date is ever invented.** An event whose end was not announced shows no end date.
That is the intended behaviour, not a gap to fill in.
**Asking for a game?** Include the page you would want it read from. Some sources have
already been assessed and declined — a wiki that forbids automated access, hides its
data behind a bot check, or only archives finished events cannot be used. The current
list is in `AGENTS.md` under "Sources assessed and declined", so you can check before
writing it up.
- type: dropdown
id: kind
attributes:
label: What kind of request is this?
options:
- A game the app does not cover
- A better or extra source for a game it does cover
- The list, calendar or sorting
- Dailies, streaks or the checklist
- Events I enter myself
- Offline, installing, or updating
- Export or import
- Something else
validations:
required: true
- type: textarea
id: problem
attributes:
label: What are you trying to do?
description: |
The situation, rather than the solution. Knowing what you were doing when the app got
in your way often turns up a smaller fix than the one either of us would have guessed.
placeholder: |
I play six games and only two of them are on here, so I still end up opening tabs.
validations:
required: true
- type: textarea
id: idea
attributes:
label: What would you like it to do?
validations:
required: false
- type: input
id: source
attributes:
label: Source page
description: |
For a game or source request: the page that lists that game's events, with real dates
on it. A page whose newest event has already finished cannot be used.
placeholder: https://bluearchive.wiki/wiki/Events
validations:
required: false
+6 -1
View File
@@ -114,7 +114,7 @@ src/client/ React app, service worker, manifest
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health serve.ts static server + /api/health
test/ 480 tests test/ 494 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README snapshots/ current page per source, rewritten by refresh — see its README
``` ```
@@ -252,6 +252,11 @@ A source whose ToS forbids automated access does not get an adapter. Flag it and
| `bluearchive.fandom.com` | **Declined.** Fetches and parses fine; the page is the problem. Its `Event/Event_List` is a JP-server archive whose newest entry ended 2026-02-18, so all 88 rows are history and it yields **zero** live or upcoming events. An adapter would put an empty lane on the calendar and, because the runner rejects a body that parses to nothing, report a broken source forever. Same failure as the Infinity Nikki Game8 page, further along | | `bluearchive.fandom.com` | **Declined.** Fetches and parses fine; the page is the problem. Its `Event/Event_List` is a JP-server archive whose newest entry ended 2026-02-18, so all 88 rows are history and it yields **zero** live or upcoming events. An adapter would put an empty lane on the calendar and, because the runner rejects a body that parses to nothing, report a broken source forever. Same failure as the Infinity Nikki Game8 page, further along |
| `bluearchive.wiki`, `prydwen.gg`, `gametora.com` | **Cleared, unbuilt.** `User-agent: *` allows the paths we would want. prydwen sets `Crawl-delay: 10`, far below our one-per-6h. `bluearchive.wiki` is the live Blue Archive source worth building — `Name (EN) \| Name (JP) \| Start date \| End date` with ISO dates, current to 2026-09-15 — but note it is Miraheze and its `robots.txt` **disallows** `/w/` and `/*?action=`, so the API route is closed there and only the rendered `/wiki/Events` page is permitted. It also lists JP and Global in separate tables, which is the akwiki hazard: publishing the wrong server's date | | `bluearchive.wiki`, `prydwen.gg`, `gametora.com` | **Cleared, unbuilt.** `User-agent: *` allows the paths we would want. prydwen sets `Crawl-delay: 10`, far below our one-per-6h. `bluearchive.wiki` is the live Blue Archive source worth building — `Name (EN) \| Name (JP) \| Start date \| End date` with ISO dates, current to 2026-09-15 — but note it is Miraheze and its `robots.txt` **disallows** `/w/` and `/*?action=`, so the API route is closed there and only the rendered `/wiki/Events` page is permitted. It also lists JP and Global in separate tables, which is the akwiki hazard: publishing the wrong server's date |
`.github/ISSUE_TEMPLATE/feature_request.yml` points readers at that table by heading, so a source
request can be checked against it before anyone writes it up — the loudest feedback on the first
release was "not enough games" (`docs/FEEDBACK.md`), which makes this the request that arrives most.
Keep the heading if the section moves.
wiki.gg hosts (`arknights`, `endfield`) carry `Content-Signal: search=yes, ai-train=no, use=reference` wiki.gg hosts (`arknights`, `endfield`) carry `Content-Signal: search=yes, ai-train=no, use=reference`
with `Allow: /`, and disallow `ClaudeBot` and other AI crawlers by name. Our fetcher is neither: it with `Allow: /`, and disallow `ClaudeBot` and other AI crawlers by name. Our fetcher is neither: it
trains nothing, and no LLM reads the page content — constraint 2 is what keeps that true, so it is trains nothing, and no LLM reads the page content — constraint 2 is what keeps that true, so it is
+21
View File
@@ -209,6 +209,27 @@ six-hour floor is checked per source, there are no retries (a retry is a second
`robots.txt` that cannot be read means *do not fetch* rather than *assume yes*. Text scraped from a `robots.txt` that cannot be read means *do not fetch* rather than *assume yes*. Text scraped from a
page is sanitized at the ingest boundary before it reaches the feed, the browser or your disk. page is sanitized at the ingest boundary before it reaches the feed, the browser or your disk.
## Found a problem, or want something?
- **[Report a problem](https://github.com/StereotypicalCat/gacha-event-tracker/issues/new?template=bug_report.yml)**
— a wrong date, a missing event, a lost tick, anything the app got wrong.
- **[Request a feature](https://github.com/StereotypicalCat/gacha-event-tracker/issues/new?template=feature_request.yml)**
— including a game it does not cover yet, which is the most common ask by a distance.
Both are forms rather than a blank box, for one reason: nothing you mark, type or tick ever leaves
your browser, so there is no account and no server-side record for anyone to look up afterwards.
Whatever the report says is all there is. The bug form arrives with the footer's "event data last
refreshed" line already filled in, because a stale calendar and a genuinely wrong date look
identical from the outside and only that line tells them apart.
If a date looks wrong, **open the event and check its source link first.** This app rearranges what a
wiki published; it does not compile schedules. When the wiki says the same thing, the fix belongs
there — though it is still worth reporting, since a game with a consistently wrong source may need a
better one.
Blank issues are still open for anything that fits neither form. The templates themselves live in
[`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE).
## Documentation ## Documentation
| Document | Covers | | Document | Covers |
+64 -1
View File
@@ -4,6 +4,34 @@ import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker"; 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. */ /** Who built this, and where to find them. */
export const AUTHOR = { export const AUTHOR = {
name: "Lucas Winther", name: "Lucas Winther",
@@ -65,6 +93,13 @@ export function Colophon({
const studios = [...new Set(games.map((g) => g.studio))]; const studios = [...new Set(games.map((g) => g.studio))];
const { refreshedAt, stale } = freshness(sources, now); 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 sites = [...new Map(sources.map((s) => {
const site = siteFor(s.url); const site = siteFor(s.url);
return [site.name, site] as const; return [site.name, site] as const;
@@ -95,7 +130,7 @@ export function Colophon({
<time dateTime={refreshedAt} className="text-muted"> <time dateTime={refreshedAt} className="text-muted">
{formatAbsolute(refreshedAt, true)} {formatAbsolute(refreshedAt, true)}
</time> </time>
{`${formatRemaining(now - Date.parse(refreshedAt))} ago.`} {`${ago} ago.`}
</> </>
)} )}
</p> </p>
@@ -220,6 +255,34 @@ export function Colophon({
Source code Source code
</a> </a>
</p> </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> </footer>
); );
} }
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import {
BUG_URL,
Colophon,
FEATURE_URL,
bugReportUrl,
} from "../src/client/components/Colophon.tsx";
/**
* The issue forms, and the app's links into them.
*
* These pin a seam nothing else can see: GitHub answers a malformed form with a
* rendered error only a visitor notices, and it drops a prefill parameter that
* names no field with no error at all. Both failures look exactly like a working
* link from here, so the only place they can be caught is a test that reads the
* YAML the links point at.
*
* Reads repo files, never build output — see AGENTS.md § Commands.
*/
const DIR = ".github/ISSUE_TEMPLATE";
type Field = {
type: string;
id?: string;
attributes?: { label?: string };
validations?: { required?: boolean };
};
type Form = { name?: string; description?: string; labels?: string[]; body?: Field[] };
async function form(file: string): Promise<Form> {
const text = await Bun.file(`${DIR}/${file}`).text();
return Bun.YAML.parse(text) as Form;
}
/** The `template=` filename a link points at, and its prefill parameters. */
function link(url: string): { template: string | null; params: URLSearchParams } {
const parsed = new URL(url);
const params = parsed.searchParams;
return { template: params.get("template"), params };
}
describe("issue forms", () => {
for (const file of ["bug_report.yml", "feature_request.yml"]) {
test(`${file} is a well-formed issue form`, async () => {
const doc = await form(file);
expect(doc.name).toBeTruthy();
expect(doc.description).toBeTruthy();
expect(doc.body?.length).toBeGreaterThan(0);
for (const field of doc.body ?? []) {
// GitHub rejects an unknown type outright.
expect([
"markdown",
"input",
"textarea",
"dropdown",
"checkboxes",
]).toContain(field.type);
// Every field that collects an answer needs a label to collect it under,
// and an id, or its answer cannot be prefilled or referenced.
if (field.type !== "markdown") {
expect(field.attributes?.label).toBeTruthy();
expect(field.id).toBeTruthy();
}
}
});
test(`${file} has unique field ids`, async () => {
const ids = (await form(file)).body?.flatMap((f) => f.id ?? []) ?? [];
expect(new Set(ids).size).toBe(ids.length);
});
test(`${file} asks for something, but not for everything`, async () => {
const answered = (await form(file)).body?.filter((f) => f.type !== "markdown") ?? [];
const required = answered.filter((f) => f.validations?.required === true);
// At least one required field, or the form accepts an empty report.
expect(required.length).toBeGreaterThan(0);
// A form that demands every field is one a reader abandons — and this app
// has no account to look anything up in, so an abandoned report is the
// whole loss.
expect(required.length).toBeLessThan(answered.length);
});
test(`${file} uses only labels a fresh repository already has`, async () => {
// A template naming a label that does not exist applies nothing, silently.
for (const label of (await form(file)).labels ?? []) {
expect(["bug", "enhancement"]).toContain(label);
}
});
}
test("config.yml keeps the blank-issue escape hatch open", async () => {
const text = await Bun.file(`${DIR}/config.yml`).text();
expect(Bun.YAML.parse(text)).toEqual({ blank_issues_enabled: true });
});
});
describe("the app's links into them", () => {
test("each points at a template file that exists", async () => {
for (const url of [BUG_URL, FEATURE_URL]) {
const { template } = link(url);
expect(template).toBeTruthy();
expect(await Bun.file(`${DIR}/${template}`).exists()).toBe(true);
}
});
test("the prefilled freshness line names a real field on the bug form", async () => {
const ids = (await form("bug_report.yml")).body?.flatMap((f) => f.id ?? []) ?? [];
const { params } = link(bugReportUrl("15 Aug 2026, 04:12 — 2 days ago"));
for (const key of params.keys()) {
if (key === "template") continue;
// The failure this catches: renaming the field id leaves the link working
// and the prefill gone, with nothing anywhere to say so.
expect(ids).toContain(key);
}
expect(params.get("refreshed")).toBe("15 Aug 2026, 04:12 — 2 days ago");
});
test("every template link in the README names a template that exists", async () => {
// A `?template=` that names nothing lands the reader on the chooser instead,
// which looks close enough to working that nobody reports it.
const readme = await Bun.file("README.md").text();
const named = [...readme.matchAll(/issues\/new\?template=([\w.-]+)/g)].map(
(m) => m[1],
);
expect(named.length).toBeGreaterThan(0);
for (const template of named) {
expect(await Bun.file(`${DIR}/${template}`).exists()).toBe(true);
}
// Both forms are reachable from the README, not just the one.
expect(new Set(named)).toEqual(
new Set(["bug_report.yml", "feature_request.yml"]),
);
});
test("the footer offers both, and carries its own freshness line into the bug form", () => {
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
const html = renderToStaticMarkup(
<Colophon
sources={[
{
sourceId: "genshin-game8-events",
game: "genshin",
url: "https://game8.co/games/Genshin-Impact/archives/301601",
lastSuccessAt: new Date(NOW - 3 * 60 * 60 * 1000).toISOString(),
eventCount: 9,
},
]}
now={NOW}
/>,
);
expect(html).toContain("Report a problem");
expect(html).toContain("request a feature");
expect(html).toContain("template=feature_request.yml");
// The age the reader is being shown is the age the report will carry.
expect(html).toContain("3h 0m ago");
expect(html).toContain(encodeURIComponent("3h 0m ago"));
});
test("an unfetched feed prefills nothing rather than an empty value", () => {
// "" in the field reads as an answer the reader gave, and a report claiming
// the app never refreshed is worse than one that leaves the question open.
expect(bugReportUrl(null)).toBe(BUG_URL);
expect(link(bugReportUrl(null)).params.has("refreshed")).toBe(false);
});
});