Only warn about staleness for games the reader has switched on
The footer's staleness notice is an instruction, and the only remedy it offers is "go and check that game's source page". That is not something a reader can act on for a game they turned off and cannot see a single row of — and with nineteen lanes, naming eleven they do not play buries the one they do. A new lane arrives switched off (F8), so an untouched install was being warned about most of the catalogue. Scoped in two places, not one: the named list, and the count the summarising branch measures against. Without the second, that branch would never fire for a reader with most of the calendar off and they would get a list where a sentence was the readable answer. The notice now also states whose games it counted, in the words NextUp already uses for the same set. 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 job is being trusted about age. The headline age and the credits stay whole: the first is a fact about the feed and feeds the bug form, the second is attribution owed to every source we read regardless of what is on screen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8b3d130521
commit
393ea594ea
@@ -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
|
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
|
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.
|
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.
|
||||||
|
|||||||
+15
@@ -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
|
stale, its lane carries a warning badge. Never present stale data as current — the whole value
|
||||||
proposition is trust in the dates.
|
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.**
|
**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
|
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
|
on a train in daylight and by people who find a dark UI harder rather than moodier. A public fork
|
||||||
|
|||||||
+8
-1
@@ -684,7 +684,14 @@ export function App() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Colophon sources={state.feed.sources} now={now} />
|
{/* `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. */}
|
||||||
|
<Colophon
|
||||||
|
sources={state.feed.sources}
|
||||||
|
now={now}
|
||||||
|
hiddenGames={prefs.hiddenGames}
|
||||||
|
/>
|
||||||
|
|
||||||
{lastIgnored !== null && (
|
{lastIgnored !== null && (
|
||||||
<Toast
|
<Toast
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
|
import type { LaneId } from "../../shared/custom.ts";
|
||||||
import { freshness, type SourceHealth } from "../../shared/feed.ts";
|
import { freshness, type SourceHealth } from "../../shared/feed.ts";
|
||||||
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
|
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
|
||||||
|
|
||||||
@@ -134,9 +135,16 @@ function siteFor(url: string): { name: string; url: string } {
|
|||||||
export function Colophon({
|
export function Colophon({
|
||||||
sources,
|
sources,
|
||||||
now,
|
now,
|
||||||
|
hiddenGames = [],
|
||||||
}: {
|
}: {
|
||||||
sources: SourceHealth[];
|
sources: SourceHealth[];
|
||||||
now: number;
|
now: number;
|
||||||
|
/**
|
||||||
|
* Lanes the reader has switched off, so the staleness notice can leave them
|
||||||
|
* out. Optional and empty by default: a caller that does not know the
|
||||||
|
* reader's preferences gets the whole list rather than none of it.
|
||||||
|
*/
|
||||||
|
hiddenGames?: readonly LaneId[];
|
||||||
}) {
|
}) {
|
||||||
// The tree's resolver rather than the module lookup: it is the one that
|
// The tree's resolver rather than the module lookup: it is the one that
|
||||||
// answers for a reader's own games, and the one that reads a hue for the
|
// answers for a reader's own games, and the one that reads a hue for the
|
||||||
@@ -148,6 +156,26 @@ 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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// Built once so the sentence a reader reads and the value the bug form is
|
||||||
// prefilled with cannot drift apart.
|
// prefilled with cannot drift apart.
|
||||||
const ago =
|
const ago =
|
||||||
@@ -196,7 +224,7 @@ export function Colophon({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{stale.length > 0 && (
|
{shownStale.length > 0 && (
|
||||||
// Named per game rather than counted, because a count is not something a
|
// 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
|
// 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.
|
// 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
|
// stopped running looks like. Ten names each repeating the same age is
|
||||||
// less readable than the count this replaced, and the headline above
|
// less readable than the count this replaced, and the headline above
|
||||||
// already gives the date — so that case gets a sentence, not a list.
|
// 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.
|
||||||
<p className="mt-2 text-soon">
|
<p className="mt-2 text-soon">
|
||||||
{stale.length === games.length ? (
|
{shownStale.length === shownGames.length ? (
|
||||||
`Nothing has refreshed in over two days, so any end date here may have moved.`
|
`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
|
Of the games you have switched on,{" "}
|
||||||
refreshed in over two days, so some of their end dates may have
|
{shownStale.length === 1 ? "this one has" : "these have"} not
|
||||||
moved:{" "}
|
refreshed in over two days, so some end dates may have moved:{" "}
|
||||||
{stale.slice(0, STALE_NAMES).map((s, i, shown) => (
|
{shownStale.slice(0, STALE_NAMES).map((s, i, shown) => (
|
||||||
<span key={s.game}>
|
<span key={s.game}>
|
||||||
{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}
|
{gameMeta(s.game).name}
|
||||||
{s.lastSuccessAt === null
|
{s.lastSuccessAt === null
|
||||||
? " (never)"
|
? " (never)"
|
||||||
: ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`}
|
: ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{stale.length > STALE_NAMES &&
|
{shownStale.length > STALE_NAMES &&
|
||||||
` and ${stale.length - STALE_NAMES} other game${
|
` and ${shownStale.length - STALE_NAMES} other game${
|
||||||
stale.length - STALE_NAMES > 1 ? "s" : ""
|
shownStale.length - STALE_NAMES > 1 ? "s" : ""
|
||||||
}`}
|
}`}
|
||||||
{"."}
|
{"."}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+83
-2
@@ -286,7 +286,7 @@ describe("Colophon freshness notice (PRD F7)", () => {
|
|||||||
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
||||||
}));
|
}));
|
||||||
const html = renderToStaticMarkup(<Colophon sources={behind} now={NOW} />);
|
const html = renderToStaticMarkup(<Colophon sources={behind} now={NOW} />);
|
||||||
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 (");
|
expect(html).not.toContain("Genshin Impact (");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ describe("Colophon freshness notice (PRD F7)", () => {
|
|||||||
<Colophon sources={[{ ...fresh, lastSuccessAt: null }]} now={NOW} />,
|
<Colophon sources={[{ ...fresh, lastSuccessAt: null }]} now={NOW} />,
|
||||||
);
|
);
|
||||||
expect(html).toContain("no source has been fetched yet");
|
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", () => {
|
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)");
|
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(
|
||||||
|
<Colophon
|
||||||
|
sources={[
|
||||||
|
fresh,
|
||||||
|
{ ...fresh, sourceId: "nikki-game8-events", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
||||||
|
]}
|
||||||
|
now={NOW}
|
||||||
|
hiddenGames={["nikki"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Colophon
|
||||||
|
sources={[
|
||||||
|
fresh,
|
||||||
|
{ ...fresh, sourceId: "nikki-src", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
||||||
|
{ ...fresh, sourceId: "hsr-src", game: "hsr", lastSuccessAt: new Date(NOW - 90 * HOUR).toISOString() },
|
||||||
|
]}
|
||||||
|
now={NOW}
|
||||||
|
hiddenGames={["hsr"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Colophon
|
||||||
|
sources={[
|
||||||
|
fresh,
|
||||||
|
{ ...fresh, sourceId: "nikki-src", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
||||||
|
{ ...fresh, sourceId: "hsr-src", game: "hsr", lastSuccessAt: new Date(NOW - 90 * HOUR).toISOString() },
|
||||||
|
]}
|
||||||
|
now={NOW}
|
||||||
|
hiddenGames={["genshin"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Colophon
|
||||||
|
sources={[
|
||||||
|
{ ...fresh, sourceId: "nikki-src", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
||||||
|
]}
|
||||||
|
now={NOW}
|
||||||
|
hiddenGames={["nikki"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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", () => {
|
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
|
// 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
|
// other people. Reading order follows that, so a reader scanning the footer
|
||||||
|
|||||||
Reference in New Issue
Block a user