diff --git a/AGENTS.md b/AGENTS.md index 5257eb9..fad537e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1055,3 +1055,13 @@ to an open page). Four things hold it up: so one live wiki cannot vouch for a stalled sibling. Given that thirteen sources cannot be fetched from CI at all (§ Scraping conduct), this disclosure is the only thing standing between a reader and a confidently stale calendar — do not let a future change source it from the build clock. + **The notice under it is scoped to the reader's lanes; the line itself is not.** The age is a fact + about the feed, but the list beneath it is an instruction whose only remedy is "check that game's + source page" — unactionable for a game they switched off, and with nineteen lanes and two on it + buries the one they can do something about. So `Colophon` takes `prefs.hiddenGames` and filters + both the named list *and* the count the summarising branch measures against, or that branch would + never fire for a reader with most of the calendar off. Two things must not follow from it: the + credits are attribution and stay whole, and the notice **says** it is scoped — narrowing what the + footer measured while still printing "nothing has refreshed" would turn a claim about four games + into one about eighteen, in the one paragraph on the page whose whole job is being trusted about + age. diff --git a/docs/PRD.md b/docs/PRD.md index 60faa1b..0883ff0 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -447,6 +447,21 @@ The footer shows when the feed was last updated, per game. If a game's data is m stale, its lane carries a warning badge. Never present stale data as current — the whole value proposition is trust in the dates. +**The staleness notice names only the games the reader has switched on, and says that it does.** +The headline age is about the feed and stays whole; the notice under it is an *instruction*, and the +only remedy it offers is "go and check that game's source page" — which a reader cannot act on for a +game they turned off and cannot see a single row of. With nineteen lanes and two switched on, naming +eleven they do not play buries the one they do, and a warning about games nobody reads is a warning +nobody reads (F8: a game we add is a game they never asked for, and arrives switched off, so an +untouched install would be warned about the whole catalogue). + +Both halves of that are load-bearing. Scoping it silently would be the worse half: "nothing has +refreshed" is a claim about the whole calendar, so a reader with fourteen of eighteen lanes off would +read a sentence about four as one about all of them. The notice therefore states whose games it +counted, in the same words the empty list uses — *the games you have switched on*. Credit in the +column beside it is owed to every source we read and is not scoped; nor is the "last refreshed" line, +which reports the feed and feeds the bug form. + **F15 — Light mode, with dark still the default.** The app is a lit instrument panel and that is what it should be on first sight, but it is also read on a train in daylight and by people who find a dark UI harder rather than moodier. A public fork diff --git a/src/client/App.tsx b/src/client/App.tsx index 21bba37..cf45dba 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -684,7 +684,14 @@ export function App() {

)} - + {/* `hiddenGames` rather than `enabled`: the footer needs to know what the + reader turned *off*, and a lane absent from both is one the feed has + and this reader has never been offered — still not theirs to chase. */} + {lastIgnored !== null && ( g.studio))]; const { refreshedAt, stale } = freshness(sources, now); + /** + * The staleness notice covers the reader's own lanes and nothing else. + * + * Credit is owed to every source we read, so `games` above stays whole. This + * paragraph is the opposite kind of sentence: it is an *instruction*, and the + * only remedy it offers is "go and check that game's source page" — which is + * not something a reader can act on for a game they switched off and cannot + * see a single row of. Naming eleven lanes they do not play also buries the + * one they do, and a warning about games nobody reads is a warning nobody + * reads (§ Telling the reader to do something is not the same as showing it + * to them). + * + * `shownGames` is narrowed alongside it because the summarising branch below + * turns on "every game is behind" — measured against the same set the notice + * is allowed to name, or it would never fire for a reader with most of the + * calendar switched off. + */ + const shownGames = games.filter((g) => !hiddenGames.includes(g.id)); + const shownStale = stale.filter((s) => !hiddenGames.includes(s.game)); + // Built once so the sentence a reader reads and the value the bug form is // prefilled with cannot drift apart. const ago = @@ -196,7 +224,7 @@ export function Colophon({ )}

- {stale.length > 0 && ( + {shownStale.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. @@ -205,26 +233,34 @@ export function Colophon({ // 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. + // + // Both branches say whose games they are counting, in the words + // `NextUp` already uses for the same set. Scoping this silently would + // be the worse half of the change: "nothing has refreshed" is a claim + // about the whole calendar, and a reader who has fourteen of eighteen + // lanes off would read a sentence about four as one about all of them + // — a footer whose one job is being trusted about age must not narrow + // what it measured without saying so.

- {stale.length === games.length ? ( - `Nothing has refreshed in over two days, so any end date here may have moved.` + {shownStale.length === shownGames.length ? ( + `None of the games you have switched on have 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) => ( + Of the games you have switched on,{" "} + {shownStale.length === 1 ? "this one has" : "these have"} not + refreshed in over two days, so some end dates may have moved:{" "} + {shownStale.slice(0, STALE_NAMES).map((s, i, shown) => ( - {i > 0 && (i === shown.length - 1 && stale.length <= STALE_NAMES ? " and " : ", ")} + {i > 0 && (i === shown.length - 1 && shownStale.length <= STALE_NAMES ? " and " : ", ")} {gameMeta(s.game).name} {s.lastSuccessAt === null ? " (never)" : ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`} ))} - {stale.length > STALE_NAMES && - ` and ${stale.length - STALE_NAMES} other game${ - stale.length - STALE_NAMES > 1 ? "s" : "" + {shownStale.length > STALE_NAMES && + ` and ${shownStale.length - STALE_NAMES} other game${ + shownStale.length - STALE_NAMES > 1 ? "s" : "" }`} {"."} diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index b205a8f..984581e 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -286,7 +286,7 @@ describe("Colophon freshness notice (PRD F7)", () => { lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(), })); const html = renderToStaticMarkup(); - expect(html).toContain("Nothing has refreshed in over two days"); + expect(html).toContain("None of the games you have switched on have refreshed in over two days"); expect(html).not.toContain("Genshin Impact ("); }); @@ -319,7 +319,7 @@ describe("Colophon freshness notice (PRD F7)", () => { , ); expect(html).toContain("no source has been fetched yet"); - expect(html).toContain("Nothing has refreshed in over two days"); + expect(html).toContain("None of the games you have switched on have refreshed in over two days"); }); test("marks a never-fetched source as never, beside games that have", () => { @@ -332,6 +332,87 @@ describe("Colophon freshness notice (PRD F7)", () => { expect(html).toContain("Reverse: 1999 (never)"); }); + test("says nothing about a game the reader has switched off", () => { + // The notice is an instruction, and its only remedy is "go and check that + // game's source page". A reader who turned Infinity Nikki off cannot see a + // row of it, so being told its dates may have moved is a chore with no + // point — and naming lanes they do not play buries the one they do. + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("not refreshed in over two days"); + expect(html).not.toContain("Infinity Nikki ("); + // Credit is owed to every source we read regardless of what is on screen, + // so the game keeps its line in the thanks. + expect(html).toContain("Infinity Nikki"); + // And the headline age is unchanged: it reports the feed, not the lane. + expect(html).toContain("3h 0m ago"); + }); + + test("still names the lagging games the reader does play", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("this one has"); + expect(html).toContain("Infinity Nikki ("); + expect(html).not.toContain("Honkai: Star Rail ("); + // And it says whose games it counted. Narrowing what the footer measures + // without saying so would leave a reader with most lanes off reading a + // sentence about two games as one about the whole calendar. + expect(html).toContain("Of the games you have switched on"); + }); + + test("summarises against the games the reader can see, not the feed", () => { + // Both of this reader's two lanes are behind, so "nothing has refreshed" is + // the true sentence for them — even though a third, hidden game is current. + // Measuring against the whole feed instead would list them one by one and + // never reach this branch for anyone with most of the calendar switched off. + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("None of the games you have switched on have refreshed in over two days"); + expect(html).not.toContain("Infinity Nikki ("); + }); + + test("a reader with every lagging lane switched off sees no notice at all", () => { + const html = renderToStaticMarkup( + , + ); + // Not the summarising branch either: zero of zero shown games is not + // "nothing has refreshed", it is nothing to say. + expect(html).not.toContain("not refreshed in over two days"); + expect(html).not.toContain("None of the games you have switched on have refreshed in over two days"); + }); + test("the author's links sit together, above the ideas credit", () => { // The row is who built this and where to find them; the credit below it is // other people. Reading order follows that, so a reader scanning the footer