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:
co-authored by
Claude Opus 5.5
parent
a39c6a8398
commit
094e67d985
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user