diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..90c2ff5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b07504a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8b42eff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a2eeb94..10a8522 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ src/client/ React app, service worker, manifest 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) serve.ts static server + /api/health -test/ 480 tests +test/ 494 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever 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.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` 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 diff --git a/README.md b/README.md index b8a0737..96cd5d8 100644 --- a/README.md +++ b/README.md @@ -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 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 | Document | Covers | diff --git a/src/client/components/Colophon.tsx b/src/client/components/Colophon.tsx index 048898a..27b2694 100644 --- a/src/client/components/Colophon.tsx +++ b/src/client/components/Colophon.tsx @@ -4,6 +4,34 @@ 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", @@ -65,6 +93,13 @@ export function Colophon({ 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; @@ -95,7 +130,7 @@ export function Colophon({ - {` — ${formatRemaining(now - Date.parse(refreshedAt))} ago.`} + {` — ${ago} ago.`} )}

@@ -220,6 +255,34 @@ export function Colophon({ Source code

+ + {/* + 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. + */} +

+ Something wrong, missing, or worth adding?{" "} + + Report a problem + {" "} + or{" "} + + request a feature + + {"."} +

); } diff --git a/test/issue-templates.test.tsx b/test/issue-templates.test.tsx new file mode 100644 index 0000000..065e715 --- /dev/null +++ b/test/issue-templates.test.tsx @@ -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
{ + 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( + , + ); + + 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); + }); +});