docs: record per-game switches for chores and built-ins

The data model, PRD F17, README and DESIGN described one global chores
switch and a checkbox per game for built-ins. Adds the spec and plan, and
ticks the TODO item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-14 02:20:40 +02:00
co-authored by Claude Opus 5.5
parent a39c6a8398
commit 094e67d985
7 changed files with 257 additions and 11 deletions
+2 -1
View File
@@ -197,7 +197,8 @@ No wiki publishes those, so they are a fixed list in the app rather than scraped
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
asked for — so **Show each game's own daily chores** in the settings turns them off, for every game
or, under *Choose games*, for only the ones you pick. 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.
+2 -1
View File
@@ -4,9 +4,10 @@
(for example, five check-ins per week). Keep unfinished events in Dailies, show progress
against the required count, and distinguish finishing a week from finishing the event.
Shipped 2026-10-13 — see the [spec](docs/superpowers/specs/2026-10-03-check-in-goals-design.md).
- [ ] Rework how the *What you see* settings group handles its toggles, so that the daily chores
- [x] Rework how the *What you see* settings group handles its toggles, so that the daily chores
and the built-in weekly tasks can each be switched **globally and per game**, without adding a
new checkbox for every game.
Shipped 2026-10-14 — see the [spec](docs/superpowers/specs/2026-10-14-per-game-switches-design.md).
- Today the two are inconsistent: the daily chores have one global switch
(`prefs.showChores`) and no per-game control, while the built-in weekly tasks have only
per-game switches (`prefs.routineGames`), one checkbox per game that has any. With more
+20 -7
View File
@@ -221,11 +221,21 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
// focusGame, sort, view,
// timelineDayWidth, timelineGroup, showUpcoming,
// timelineSplitUpcoming, detectDaily, showChores,
// choresOn?, choresOff?,
// showWeeklyTasks, showMonthlyTasks, showCompleted, showIgnored, theme, regionConfirmed,
// onboarded, routineGames?, routinesAsked? }
// routineGames is the games whose built-in weekly tasks
// the reader turned on; routinesAsked the games already
// asked about. Both absent means none — built-ins are
// onboarded, showRoutines?, routineGames?, routinesOff?,
// routinesAsked? }
// Chores and built-in weekly tasks are each one global
// answer plus the games that differ (src/shared/perGame.ts):
// an on-list wins, then an off-list, then the global answer.
// showChores is the chores' global answer (default true);
// choresOn / choresOff the exceptions. showRoutines is the
// built-ins' — absent means never answered, read as off,
// and while absent the strip may ask about one game.
// routineGames is the games whose built-ins are on (the
// on-list, unchanged from before showRoutines existed),
// routinesOff the off-list, routinesAsked the games already
// asked about. Every list absent means none — built-ins are
// opt-in for every install. PRD F17.
// showWeeklyTasks / showMonthlyTasks say whether tasks of
// that period sit in the strip's row under the dailies.
@@ -529,9 +539,12 @@ removes the task record only.
**Built-in tasks** (`src/shared/routines.ts`) are not stored: they ship in the bundle, each with a
`source` URL and the `asOf` date that page gives, and their ticks and numbers are stored under their
`routine:` id exactly as a reader's task's are. Two prefs govern them: `routineGames` (games whose
built-ins are on) and `routinesAsked` (games already asked), both **absent meaning none** so they
are opt-in for every install. *Make it mine* adds a `mytask:` with `from: <routine id>` and copies
`routine:` id exactly as a reader's task's are. Four prefs govern them: `showRoutines` (the answer
for every game; **absent means never answered**, read as off), `routineGames` and `routinesOff`
(games that differ from it, on and off), and `routinesAsked` (games the strip already asked about).
The lists are **absent meaning none**, so built-ins are opt-in for every install. `routineGames`
predates `showRoutines` and kept its meaning when it became the on-list — nothing was migrated, and
a tab still running an older shell reads it the same way. *Make it mine* adds a `mytask:` with `from: <routine id>` and copies
the routine's day log and amounts to it — additively, so deleting the copy shows the original
with its own numbers again.
+5
View File
@@ -425,6 +425,11 @@ selected tab fills with `raised` and turns ink; unselected tabs are faint and go
- **Built-in question** (top of the dailies strip, once per game): one line of muted text —
`Track Zenless Zone Zero's built-in weekly tasks?` — with a near-coloured **Yes** and a faint **No
thanks** as plain text buttons. Never a card, never a modal; the strip renders for it alone.
- **Per-game switch** (*What you see*, under the chores and built-in switches): a faint 12px line
stating the exceptions — `Every game`, `All but Genshin Impact`, `Only 3 games` — then a muted,
underlined **Choose games** text button (**Done** while open). Open, a wrap of game chips in the
game-focus chip style: the game's hue with a 12% wash when on, a 30% hue border and muted ink when off,
`aria-pressed`, in the reader's game order. Closed every visit; never a checkbox per game.
- **Check-in goal control** (detail sheet, under the daily checklist): the status pills' style —
Off / Each week / Across the event — then a small number field and, for weeks, a weekday select,
both on `ground` with a hairline border like the notes field. Once a goal is set, a smaller
+4 -2
View File
@@ -580,8 +580,10 @@ default) or **reach a number** (a target such as 2,100, with an optional unit).
**Some games' weekly tasks are built in** — ZZZ's Ridu Weekly (1,300 points) and Endfield's Weekly
Routine (10 points) so far, each from a cited page and shown with the date that page gives, so a
figure from an old guide is visibly old. They are **off until the reader says yes**: the strip asks
once, in one line, about a game on screen that has them, and a switch per game in *What you see*
governs them after. Built-ins are not edited in place; **Make it mine** copies one into the reader's
once, in one line, about a game on screen that has them — until the reader answers for every game —
and *What you see* governs them after: one switch for all of them, and *Choose games* for the games
that differ. The standing daily chores take the same shape, so a reader who wants Genshin's chore
and not Star Rail's can say so without losing either game's events. Built-ins are not edited in place; **Make it mine** copies one into the reader's
own tasks with its ticks and numbers, and hides the original while the copy exists.
**F18 — What changed.**
@@ -0,0 +1,114 @@
# Per-game switches Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Daily chores and built-in weekly tasks each get a global switch plus per-game exceptions, set from a *Choose games* disclosure in *What you see*.
**Architecture:** A pure `PerGame` rule (`src/shared/perGame.ts`) — global answer, on-list, off-list — is the one place visibility is decided. Prefs gain optional exception lists; `routineGames` becomes the built-ins' on-list unchanged. Consumers (`dailyGroups`, `activeRoutines`, `routineToAsk`, `Controls`) read the rule through `choreRule` / `routineRule`.
**Tech Stack:** Bun 1.3, React 19, TypeScript strict, Tailwind, `bun test`.
**Spec:** `docs/superpowers/specs/2026-10-14-per-game-switches-design.md`
## Global Constraints
- No migration: every existing stored pref keeps its meaning; absent means unanswered.
- `showRoutines` absent is treated as off (built-ins stay opt-in).
- No new dependency; colours are tokens or game hues only (AGENTS.md § Conventions).
- Commits wait to be asked (AGENTS.md § Conventions); each task ends green and stage-ready, split
model → store → UI → docs.
## Review Focus
- Reader with `routineGames: ["zzz"]`, nothing else → ZZZ built-ins still shown (Task 2 carry-over test).
- Reader flips global chores off with an exception on Genshin → Genshin's chore stays (Task 1, Task 3).
- Global built-ins answered → strip stops asking (Task 2).
- Hand-edited storage with a game in both lists → shown (Task 1).
- Garbage in new prefs (`choresOff: "x"`, `showRoutines: 1`) → sanitised to absent (Task 2).
---
### Task 1: The `PerGame` rule
**Files:** Create `src/shared/perGame.ts`; Test `test/per-game.test.ts`
**Produces:** `type PerGame`, `shownFor(rule, game): boolean`, `setFor(rule, game, value): { on: string[]; off: string[] }`, `exceptionLine(rule, games, name): string`.
- [ ] Write tests: truth table (no lists → `all`; on-list → true under `all:false`; off-list → false under `all:true`; both lists → true); `setFor` adds only differences, removes from the other list, removes when value equals `all`; `exceptionLine` cases `Every game`, `All but Genshin Impact`, `All but 3 games`, `No game`, `Only Zenless Zone Zero`, `Only 3 games`, counting only games in the given list.
- [ ] Run `bun test test/per-game.test.ts` → FAIL (module missing).
- [ ] Implement:
```ts
export interface PerGame {
all: boolean;
on?: readonly string[] | undefined;
off?: readonly string[] | undefined;
}
export function shownFor(rule: PerGame, game: string): boolean {
if (rule.on?.includes(game)) return true;
if (rule.off?.includes(game)) return false;
return rule.all;
}
export function setFor(rule: PerGame, game: string, value: boolean): { on: string[]; off: string[] } {
const on = (rule.on ?? []).filter((g) => g !== game);
const off = (rule.off ?? []).filter((g) => g !== game);
if (value !== rule.all) (value ? on : off).push(game);
return { on, off };
}
export function exceptionLine(rule: PerGame, games: readonly string[], name: (g: string) => string): string {
const odd = games.filter((g) => shownFor(rule, g) !== rule.all);
if (odd.length === 0) return rule.all ? "Every game" : "No game";
const which = odd.length === 1 ? name(odd[0]!) : `${odd.length} games`;
return rule.all ? `All but ${which}` : `Only ${which}`;
}
```
- [ ] Run → PASS; `bun run typecheck`.
### Task 2: Prefs and routines read the rule
**Files:** Modify `src/client/state/usePrefs.ts` (Prefs interface, `restorePrefsValue`), `src/shared/routines.ts` (`activeRoutines`, `routineToAsk`), `src/client/App.tsx` (call sites); Test `test/routines.test.ts`, `test/prefs.test.ts`
**Consumes:** Task 1. **Produces:** `Prefs.choresOn?`, `choresOff?`, `showRoutines?`, `routinesOff?`; `choreRule(p): PerGame`, `routineRule(p): PerGame` (in `usePrefs.ts`); `activeRoutines(rule: PerGame, copiedFrom)`; `routineToAsk(visible, rule: PerGame, asked, answered: boolean)`.
- [ ] Tests: carry-over (`routineRule({...defaults, routineGames:["zzz"]})` → `activeRoutines` gives ZZZ only; `showRoutines: true` gives both; `routinesOff:["zzz"]` under true gives Endfield only); `routineToAsk` returns null when `answered`, skips effectively-on games; restore sanitises the three new arrays and keeps `showRoutines` only when boolean.
- [ ] Run → FAIL.
- [ ] Implement:
```ts
export function choreRule(p: Prefs): PerGame {
return { all: p.showChores, on: p.choresOn, off: p.choresOff };
}
export function routineRule(p: Prefs): PerGame {
return { all: p.showRoutines ?? false, on: p.routineGames, off: p.routinesOff };
}
```
Extend the array-sanitising loop to `choresOn`, `choresOff`, `routinesOff`; `showRoutines` → `undefined` unless boolean. `activeRoutines` filters `shownFor(rule, r.game)`; `routineToAsk` returns null if `answered`, skips `shownFor(rule, game)` or asked. App: `activeRoutines(routineRule(prefs), copiedFrom)`, `routineToAsk(visible, routineRule(prefs), prefs.routinesAsked, prefs.showRoutines !== undefined)`; Yes writes `routineGames: setFor(rule, game, true).on, routinesOff: …off`.
- [ ] Run full suite and typecheck → PASS.
### Task 3: Chores per game in the strip
**Files:** Modify `src/client/components/Dailies.tsx` (`dailyGroups`, `Dailies` prop), `src/client/App.tsx`; Test `test/views.test.tsx`
- [ ] Test: `dailyGroups(["genshin","hsr"], [], NOW, "europe", meta, startOf, (lane) => lane === "genshin")` → only `dailies:genshin`.
- [ ] Run → FAIL (type/behaviour).
- [ ] Implement: param `showChores: boolean | ((lane: LaneId) => boolean) = true`; check `typeof showChores === "function" ? showChores(lane) : showChores`. Same type on the `Dailies` prop. App passes `(lane) => shownFor(choreRule(prefs), lane)`.
- [ ] Run → PASS.
### Task 4: The disclosure in *What you see*
**Files:** Modify `src/client/components/Controls.tsx`; Test `test/controls.test.tsx`
- [ ] Tests: chores line `Every game` by default and `All but Honkai: Star Rail` with `choresOff:["hsr"]`; `Choose games` button present, chips absent until opened (static render); global `Show built-in weekly tasks` checkbox offered when a listed game has built-ins, reads `showRoutines`, line `Only Zenless Zone Zero` for `routineGames:["zzz"]`; no `Built-in weekly tasks for` text anywhere; weekly-task switch offered when routines effectively on via `showRoutines: true`.
- [ ] Run → FAIL.
- [ ] Implement `PerGameSwitch` component: the `Check`, then a faint line `exceptionLine(...) · <button aria-expanded>Choose games</button>`; when open, a `flex flex-wrap gap-1.5` of chip buttons (`aria-pressed`, hue border/text when on, `title` passed per chip). Chores: games = enabled, non-custom lanes; writes `{choresOn, choresOff}` from `setFor`. Built-ins: games with routines; writes `{routineGames: on, routinesOff: off, routinesAsked: [...asked minus g, g]}`; global writes `{showRoutines}`. Replace the per-game routine checkboxes; base `taskPeriods` on `shownFor(routineRule(prefs), g)` across `games`.
- [ ] Run full suite + typecheck → PASS.
- [ ] Browser check (AGENTS.md § Commands): build shell, serve on a fresh port, open settings → *What you see*, open both disclosures; screenshot at 390px and 1280px in dark and light.
### Task 5: Docs and changelog
**Files:** `docs/DATA-MODEL.md`, `docs/PRD.md` F17, `README.md`, `docs/DESIGN.md`, `TODO.md`, `src/shared/changelog.ts` (in a follow-up commit once the feature commit's hash exists).
- [ ] Update each sentence the change made false; tick the TODO with a spec link.
- [ ] `bun test`, `bun run typecheck`.
@@ -0,0 +1,110 @@
# Per-game switches for chores and built-in tasks — design
Date: 2026-10-14. Status: approved (repository owner, 2026-10-14). TODO.md item 2.
## Problem
*What you see* governs two things the app supplies on a game's behalf, and governs them two
different ways:
- **Daily chores** — one global switch, `prefs.showChores` (default on). No per-game control: a
reader who plays Genshin daily and HSR casually cannot drop only HSR's chore.
- **Built-in weekly tasks** — per-game opt-in only, `prefs.routineGames` (absent = none), drawn as
one checkbox per game that has any. No global answer, and the checkbox list grows with every game
and every built-in we add.
Wanted: one pattern for both — a global switch, and per-game exceptions reachable without a
checkbox per game in the group.
## Decisions
1. **One rule: a global answer and two exception lists.** For each game,
on-list → shown; off-list → hidden; otherwise the global answer. Pure, in
`src/shared/perGame.ts`:
```ts
type PerGame = { all: boolean; on?: readonly string[] | undefined; off?: readonly string[] | undefined };
shownFor(rule: PerGame, game: string): boolean
setFor(rule: PerGame, game: string, value: boolean): { on: string[]; off: string[] }
```
`setFor` removes the game from both lists, then adds it to the one matching `value` **only if
`value` differs from `all`** — an exception is a difference, not a restatement. A game in both
lists (only reachable by hand-edited storage) resolves to the on-list.
2. **Storage — nothing migrated, every current answer keeps its meaning.**
| Pref | Meaning | Absent |
|---|---|---|
| `showChores` (exists) | global chores answer | `true`, as today |
| `choresOn?`, `choresOff?` (new) | games that differ | none |
| `showRoutines?` (new) | global built-ins answer | unanswered → treated as **off** (opt-in kept) |
| `routineGames?` (exists) | built-ins **on** for these games — now the on-exception list | none |
| `routinesOff?` (new) | built-ins off for these games | none |
| `routinesAsked?` (exists) | games the strip has asked about | none |
A reader with `routineGames: ["zzz"]` and nothing else sees exactly what they saw before: global
off, ZZZ on. An older tab still running the cached shell reads `routineGames` and shows the
same games. Rejected: one override map per feature with a load-time migration (an older tab would
lose the reader's built-ins); one exception list whose meaning inverts when the global flips
(flipping one switch would silently redefine every exception).
3. **Exceptions survive a global flip.** Changing the global switch rewrites nothing else; a game
the reader singled out stays singled out. Discards nothing, like every switch in this group.
4. **The strip's question still answers the same setting.** `routineToAsk` asks only while
`showRoutines` is absent (the reader has never answered globally), about a game on screen that is
effectively off and not yet in `routinesAsked`. **Yes** writes `routineGames` via `setFor` (which,
with the global off, adds the game) and records `routinesAsked`; **No thanks** records
`routinesAsked` only. Once the reader answers the global switch, the strip stops asking — they
have stated an answer for every game at once. A chip flip in settings also records the game in
`routinesAsked`, as the per-game checkbox did.
5. **UI — a disclosure under each global switch** (owner's choice of the three placements offered).
In *What you see*:
- `Show each game's own daily chores` keeps its checkbox. Under it, a faint line states the
exceptions and a **Choose games** text button opens a wrap of game chips (closed by default,
per-visit state like *show all N*).
- `Show built-in weekly tasks` is a new global checkbox, offered only when some game on the list
has built-ins, replacing the per-game checkboxes. Same line and disclosure; its hint keeps the
source-and-date disclosure per game in each chip's `title`.
- The line reads off the rule: global on → `Every game` / `All but Genshin Impact` /
`All but 3 games`; global off → `No game` / `Only Zenless Zone Zero` / `Only 3 games`.
Derived at render, never stored.
- Chips are `<button aria-pressed>` in the game-focus chip style (hue when on, faint when off),
in the reader's game order, wrapping at phone width. Chore chips list the reader's enabled
tracked games (a hidden game draws no chore anyway; a reader's own `mygame:` lanes have none).
Built-in chips list every game on the list that has built-ins, as the checkboxes did.
- The strip's Edit panel keeps its single `Daily chores` switch, bound to the global answer.
6. **Consumers read the rule, not the raw pref.**
- `dailyGroups`' `showChores` parameter accepts `boolean | ((lane) => boolean)`; App passes
`(lane) => shownFor(choreRule(prefs), lane)`. `Dailies` takes the same.
- `activeRoutines(rule, copiedFrom)` and `routineToAsk(visible, rule, asked, answered)` take the
`PerGame` rule. `Controls`' weekly/monthly switch availability uses the effective rule.
- `usePrefs.ts` exports `choreRule(prefs)` and `routineRule(prefs)` so nothing assembles a rule
from fields by hand.
## Out of scope
Monthly built-ins (none exist); per-game control of the reader's own tasks (they are already per
game by construction); changing the Edit panel.
## Testing
- `perGame`: the truth table of `shownFor`; `setFor` writes only differences and clears the other
list; both-lists resolves on.
- Carry-over: prefs with only `showChores` / `routineGames` give the same chores and built-ins as
before.
- `restorePrefsValue`: new arrays sanitised like `routineGames`, `showRoutines` kept only if
boolean, absent stays absent.
- `routineToAsk`: no question once `showRoutines` is set; skips effectively-on games.
- `dailyGroups`: a per-lane predicate drops one game's chore and keeps the other's.
- `Controls`: the exception line for each case; no per-game checkboxes; the built-in switch reads
`showRoutines`; chips render only when opened (static render: closed).
- Browser: the group opened, both disclosures expanded, at 390px and desktop width, both themes.
## Docs
`docs/DATA-MODEL.md` prefs keys, `docs/PRD.md` F17 built-ins paragraph, `README.md` chores
sentence, `docs/DESIGN.md` component entry for the disclosure, `TODO.md` ticked, changelog entry.