From 4277c28e50e1e87c3ba1ae5cfee4b90a0616e7ff Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 28 Aug 2026 05:34:08 +0200 Subject: [PATCH 1/2] Let a reader switch off the chores the app invented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's dailies carries two different things: each game's standing chore — "Commissions, resin", a fixed list nobody publishes — and any event with a checklist. The first is the app guessing at a routine the reader never asked for, and it was the only part of that strip with no way out. So the switch removes exactly that. Events keep their checklists whatever their source, including ones the reader added themselves and marked daily, which was the requirement most at risk of being filtered away by a switch aimed at something else. Nothing is discarded. The ticks live under `dailies:` and nothing here reads or writes them, so switching back on restores every logged day and every streak — the same promise `detectDaily` already makes. Defaulted on, because everyone has these today and a setting that silently removes something on upgrade is worse than one nobody notices. Gating where the chore is built rather than where it is drawn means the counts follow for free: "N still waiting on you today" is derived from the items, and a game left with nothing contributes no group at all, so the strip's own empty guard drops it rather than leaving a heading with no rows. Named for what it removes. A switch called "dailies" would read as broken while the strip stayed on screen showing the reader's own events. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++ src/client/App.tsx | 1 + src/client/components/Controls.tsx | 12 +++++ src/client/components/Dailies.tsx | 13 +++++- src/client/state/usePrefs.ts | 13 ++++++ test/controls.test.tsx | 9 ++-- test/views.test.tsx | 74 ++++++++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d16c912..fdd6219 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,12 @@ Alongside them sits **today's dailies** — commissions, sanity, daily training No wiki publishes those, so they are a fixed list in the app rather than scraped data, and they are the only thing on the page that expires tonight rather than next patch. +Because nobody publishes them, they are the app's guess at your routine rather than anything you +asked for — so **Show each game's own daily chores** in the settings turns them off. Only the +invented chores go: anything with a checklist is something you engaged with, including events you +added yourself and marked daily, and those stay where they are. Nothing is discarded, so every tick +and streak is waiting if you turn it back on. + Both roll over at **04:00 server time** in your region, not midnight, because that is when the games roll over. Finishing at 02:00 still counts as yesterday. diff --git a/src/client/App.tsx b/src/client/App.tsx index 354476a..21bba37 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -538,6 +538,7 @@ export function App() { the lanes out here would also cost a reader's own game its place in the order their repeating events are grouped under. */} r.event)} region={prefs.region} diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index b670e94..1bdb438 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -230,6 +230,18 @@ export function Controls({ hint="Guessed from what the source wrote, so it misses some and invents others. Off, only events you mark yourself get a checklist. Your ticks and streaks are kept either way." /> + {/* The chores are the app's own invention — no source publishes + "Commissions, resin" — and they were the one part of the strip + with no way out. Named for what it removes rather than for + "dailies", which would read as broken while the strip stayed on + screen showing the reader's own events. */} + onUpdate({ showChores })} + label="Show each game's own daily chores" + hint="Commissions, resin, daily training and the rest. Nobody publishes these, so the app keeps a fixed list per game. Off, the strip shows only events with a checklist. Your ticks and streaks are kept either way." + /> + {ignoredCount > 0 && ( GameMeta, startOf: (event: DisplayEvent) => number, + showChores = true, ): DailyGroup[] { // A lane can arrive through an event without being in `games` — an event on a // game the reader has since switched off, say — and dropping it here would @@ -102,7 +103,13 @@ export function dailyGroups( const today = dayKey(now, region, lane); const resetsIn = msUntilReset(now, region, lane); - if (!isCustomGameId(lane)) { + // The standing chore is the app's own invention — no source publishes + // "Commissions, resin" — so it is the one part of this strip a reader can + // reasonably want gone. Switched off it is simply not built; nothing reads + // or writes its ticks here, so `dailies:` keeps every day the reader + // ever logged and switching back on restores the lot. A game the reader + // invented never had one to begin with. + if (showChores && !isCustomGameId(lane)) { items.push({ key: dailiesId(lane as GameId), game: lane, @@ -141,6 +148,7 @@ export function Dailies({ now, daysFor, onToggleDay, + showChores, }: { /** Every lane the reader is looking at, in their own order. */ games: LaneId[]; @@ -151,6 +159,8 @@ export function Dailies({ */ events: DisplayEvent[]; region: Region; + /** Whether to carry each game's standing chore — `prefs.showChores`. */ + showChores: boolean; now: number; daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; @@ -176,6 +186,7 @@ export function Dailies({ region, gameMeta, (event) => Date.parse(event.startsAt), + showChores, ); const items = groups.flatMap((group) => group.items); const total = items.length; diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 5bcd63b..ae9eb84 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -155,6 +155,18 @@ export interface Prefs { * who already switched it on keep it — stored prefs win over this default. */ detectDaily: boolean; + /** + * Whether Today's dailies carries each game's standing chore — commissions, + * sanity, daily training. + * + * On by default, because every reader has these today and a setting that + * silently removes something on upgrade is worse than one nobody notices. + * Off hides only the chores: an event with a checklist is something the + * reader engaged with and stays either way. Like `detectDaily` it discards + * nothing — the ticks live under `dailies:` and are never written from + * here, so switching back on restores every one of them. + */ + showChores: boolean; showCompleted: boolean; /** Reveal events the reader has ignored, so they can be restored. */ showIgnored: boolean; @@ -186,6 +198,7 @@ function defaults(): Prefs { showUpcoming: false, timelineSplitUpcoming: true, detectDaily: false, + showChores: true, showCompleted: true, showIgnored: false, theme: DEFAULT_THEME_CHOICE, diff --git a/test/controls.test.tsx b/test/controls.test.tsx index f520828..89afa2a 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -30,6 +30,7 @@ const PREFS: Prefs = { showUpcoming: false, timelineSplitUpcoming: true, detectDaily: false, + showChores: true, showCompleted: true, showIgnored: false, theme: "dark", @@ -99,11 +100,13 @@ describe("Controls: what am I allowed to look at", () => { test("it reads its own preference and not a neighbour's", () => { // Both neighbours are on and this one is off, so a checkbox bound to the - // wrong key shows up as the wrong count of ticks. + // wrong key shows up as the wrong count of ticks. The chores toggle is a + // third box that is on in both renders — it defaults on — so it raises + // each count by one without changing what the delta proves. const off = checkboxes(render(PREFS)); const on = checkboxes(render({ ...PREFS, showUpcoming: true })); - expect(off.filter(Boolean)).toHaveLength(1); - expect(on.filter(Boolean)).toHaveLength(2); + expect(off.filter(Boolean)).toHaveLength(2); + expect(on.filter(Boolean)).toHaveLength(3); }); test("how unstarted events sit on the board is offered only when they are", () => { diff --git a/test/views.test.tsx b/test/views.test.tsx index f4ca1f3..3b64798 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -1014,3 +1014,77 @@ describe("boardWindow is not widened by expansion", () => { expect(chartWidthOf(withExpand)).toBe(chartWidthOf(withoutExpand)); }); }); + +describe("dailyGroups with the chores switched off", () => { + const NOW = Date.parse("2026-08-17T12:00:00.000Z"); + const meta = (id: string) => metaFor(id, {}); + const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt); + const repeating = (id: string, game: string, title: string) => + ({ + id, + game, + title, + type: "login", + summary: null, + startsAt: "2026-08-10T00:00:00.000Z", + startPrecision: "day", + endsAt: null, + endPrecision: "unknown", + regionScoped: false, + regionEnds: null, + sourceUrl: "https://example.test", + }) as never; + + test("the invented chore goes and the events stay", () => { + // Only the fixed per-game list the app makes up goes. Anything with a + // checklist is something the reader engaged with, and stays. + const groups = dailyGroups( + ["genshin"], + [repeating("e1", "genshin", "Login Bonus")], + NOW, + "europe", + meta, + startOf, + false, + ); + expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([["e1"]]); + }); + + test("an event the reader added themselves is untouched", () => { + // Stated explicitly because it is the requirement most at risk of being + // filtered away by a switch aimed at something else. + const groups = dailyGroups( + ["genshin"], + [repeating("myevent:k3f9qa2m01", "genshin", "My own grind")], + NOW, + "europe", + meta, + startOf, + false, + ); + expect(groups[0]?.items.map((i) => i.key)).toEqual(["myevent:k3f9qa2m01"]); + }); + + test("with nothing else to show, a game contributes no group at all", () => { + // Not an empty group with a heading and no rows — the strip's own + // `total === 0` guard then drops it entirely, which is what a reader who + // turned this off is asking for. + const groups = dailyGroups(["genshin", "hsr"], [], NOW, "europe", meta, startOf, false); + expect(groups).toEqual([]); + }); + + test("left on, nothing about the strip moves", () => { + const groups = dailyGroups( + ["genshin"], + [repeating("e1", "genshin", "Login Bonus")], + NOW, + "europe", + meta, + startOf, + true, + ); + expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([ + ["dailies:genshin", "e1"], + ]); + }); +}); From dd8d7577b37cc55ff0099de1a7337e8445d1652b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 28 Aug 2026 05:43:04 +0200 Subject: [PATCH 2/2] Watch the pref reach the screen, not just the function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pointed out that `dailyGroups` was well covered and the wiring to it was not: passing the wrong pref from App, binding the checkbox to a neighbour, or hardcoding `true` in Dailies each shipped with the suite green. Three of those are now caught by rendering the strip and the settings panel rather than the function behind them — verified by performing the mutations, not by reading the code. The fourth, App handing Dailies the wrong pref, is still uncovered: no test renders App, and closing it needs a feed and a click library this project does not have. Also pins the default. Flipping that one line silently empties Today's dailies for every existing reader, which is the whole argument of the comment above it, and nothing was watching — `defaults` is exported so the value and the upgrade path can both be asserted. And DATA-MODEL's enumeration of the prefs blob had gone stale; AGENTS.md names it as the doc to update when a stored key moves. Co-Authored-By: Claude Opus 5 (1M context) --- docs/DATA-MODEL.md | 5 ++-- src/client/state/usePrefs.ts | 2 +- test/controls.test.tsx | 12 ++++++++ test/prefs.test.ts | 19 ++++++++++++- test/views.test.tsx | 53 +++++++++++++++++++++++++++++++++++- 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 9f92dbe..133fb06 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -220,8 +220,9 @@ Namespaced, versioned, and small. Nothing here ever goes to the server. "gacha-tracker:v1:prefs" // { region, hiddenGames[], knownGames[]?, gameOrder[]?, // focusGame, sort, view, // timelineDayWidth, timelineGroup, showUpcoming, - // timelineSplitUpcoming, detectDaily, showCompleted, - // showIgnored, theme, regionConfirmed, onboarded } + // timelineSplitUpcoming, detectDaily, showChores, + // showCompleted, showIgnored, theme, regionConfirmed, + // onboarded } // timelineDayWidth is px per day on the board, stored as the // measurement rather than a step number and read through // snapDayWidth — so a value from an older ladder still opens diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index ae9eb84..6a1aa5b 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -186,7 +186,7 @@ export interface Prefs { onboarded: boolean; } -function defaults(): Prefs { +export function defaults(): Prefs { return { region: guessRegion(), hiddenGames: [], diff --git a/test/controls.test.tsx b/test/controls.test.tsx index 89afa2a..6afc1fc 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -275,3 +275,15 @@ describe("Controls: the game order editor", () => { ); }); }); + +describe("the chores checkbox reports its own pref", () => { + test("off, one fewer box is ticked", () => { + // Counting alone can no longer separate the two always-on boxes from each + // other — with `showCompleted` also true, a checkbox bound to the wrong one + // of them keeps the same total. Flipping this pref specifically is what + // distinguishes them. + const on = checkboxes(render(PREFS)).filter(Boolean).length; + const off = checkboxes(render({ ...PREFS, showChores: false })).filter(Boolean).length; + expect(off).toBe(on - 1); + }); +}); diff --git a/test/prefs.test.ts b/test/prefs.test.ts index edd6588..31c1305 100644 --- a/test/prefs.test.ts +++ b/test/prefs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { adoptNewLanes, adoptRenamed } from "../src/client/state/usePrefs.ts"; +import { adoptNewLanes, adoptRenamed, defaults } from "../src/client/state/usePrefs.ts"; import type { LaneId } from "../src/shared/custom.ts"; /** @@ -106,3 +106,20 @@ describe("adoptRenamed", () => { expect(adoptRenamed({})).toEqual({}); }); }); + +describe("defaults that a reader would notice losing", () => { + test("the chores are on until someone says otherwise", () => { + // Flipping this one line silently empties Today's dailies for every + // existing reader — it is the whole argument of the comment above it, and + // nothing else in the suite was watching it. + expect(defaults().showChores).toBe(true); + }); + + test("a stored blob without the key keeps the default", () => { + // The upgrade path. Prefs saved before this key existed must not read as + // "off" — `{...defaults(), ...stored}` is what guarantees that, and JSON + // cannot carry an `undefined` that would override it. + const stored = { region: "europe", detectDaily: false } as const; + expect({ ...defaults(), ...stored }.showChores).toBe(true); + }); +}); diff --git a/test/views.test.tsx b/test/views.test.tsx index 3b64798..a9ed28f 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -8,7 +8,7 @@ import { startMarkers, Timeline, } from "../src/client/components/Timeline.tsx"; -import { CatchUpPanel, dailyGroups } from "../src/client/components/Dailies.tsx"; +import { CatchUpPanel, Dailies, dailyGroups } from "../src/client/components/Dailies.tsx"; import { Welcome } from "../src/client/components/Welcome.tsx"; import { timelineLanes } from "../src/client/state/lanes.ts"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; @@ -1088,3 +1088,54 @@ describe("dailyGroups with the chores switched off", () => { ]); }); }); + +describe("the chores toggle reaches the screen", () => { + // `dailyGroups` is pure and well covered, but nothing tied `prefs.showChores` + // to what renders. Review proved four plausible breaks shipped green — + // including App passing the wrong pref, and Dailies hardcoding `true` — so + // these assert the rendered strip rather than the function behind it. + const NOW = Date.parse("2026-08-17T12:00:00.000Z"); + const repeating = { + id: "e1", + game: "genshin", + title: "Lantern Rite login", + type: "login", + summary: null, + startsAt: "2026-08-10T00:00:00.000Z", + startPrecision: "day", + endsAt: null, + endPrecision: "unknown", + regionScoped: false, + regionEnds: null, + sourceUrl: "https://example.test", + } as never; + + const strip = (showChores: boolean) => + render( + []} + onToggleDay={() => {}} + />, + ); + + test("on, the invented chore is on screen", () => { + const html = strip(true); + expect(html).toContain("Commissions, resin"); + expect(html).toContain("Lantern Rite login"); + }); + + test("off, the chore is gone and the event and its count are not", () => { + // The count is asserted too: it derives from the items, so a chore hidden + // from view but still counted would leave the reader chasing a tick that + // is not there. + const html = strip(false); + expect(html).not.toContain("Commissions, resin"); + expect(html).toContain("Lantern Rite login"); + expect(html).toContain("0/1"); + }); +});