diff --git a/README.md b/README.md index 96554b8..533dd07 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TODO.md b/TODO.md index 27333f7..ed24dfd 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index aedd200..5aa5cc0 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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: ` 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: ` and copies the routine's day log and amounts to it — additively, so deleting the copy shows the original with its own numbers again. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7ca2d12..65a89b2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/docs/PRD.md b/docs/PRD.md index 19342a8..f784ae6 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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.** diff --git a/docs/superpowers/plans/2026-10-14-per-game-switches.md b/docs/superpowers/plans/2026-10-14-per-game-switches.md new file mode 100644 index 0000000..2dcbc1d --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-per-game-switches.md @@ -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(...) · `; 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`. diff --git a/docs/superpowers/specs/2026-10-14-per-game-switches-design.md b/docs/superpowers/specs/2026-10-14-per-game-switches-design.md new file mode 100644 index 0000000..9d6b979 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-per-game-switches-design.md @@ -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 `