Files
gacha-event-tracker/docs/superpowers/plans/2026-10-13-periodic-tasks-2a.md
T
Lucas WintherandClaude Opus 5.5 e0aca3b1fb docs: plan weekly and monthly tasks, phase 2a
Task status reuses the goal status shape, so one chip, one count and one
tally serve chores, events and tasks alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:28:28 +02:00

99 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Weekly and Monthly Tasks (Phase 2a) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the reader add their own weekly and monthly tasks per game — ticked N days per period, or a number against a target (`1900 / 2100`) — shown beside the dailies, and let an event's check-in goal be a number too.
**Architecture:** One status shape for everything periodic: `GoalStatus` (from `goal.ts`) gains month scope, an `amount` measure and the period's start key, and `taskStatus` returns it. That lets the Dailies chip, its count, label and tallying serve chores, events and tasks alike. Two new stores: `tasks` (raw records kept, validated for display only) and `amounts` (a value per item per period). Both travel in the export.
**Tech Stack:** Bun, TypeScript, React 19 (static-render tests), Zod 3, Tailwind 4.
**Spec:** `docs/superpowers/specs/2026-10-13-periodic-tasks-design.md` (phase 2a), building on `docs/superpowers/specs/2026-10-03-check-in-goals-design.md`.
## Global Constraints
- No change to `GachaEvent`, event IDs, day-log keys or existing storage keys. New keys: `gacha-tracker:v1:tasks`, `gacha-tracker:v1:amounts`.
- Never drop a stored record on load: the task store keeps raw records and filters for display; the amount store never prunes a period.
- Import merges and never removes: tasks by id, later `updatedAt` wins; amounts per item **and** period, later `at` wins; malformed incoming entries are skipped.
- `Task.game` is `z.string()`, never `GameId` (AGENTS.md § retirement rule).
- `mytask` joins `RESERVED_ID_SEGMENTS` (and `routine`, reserved now for 2b).
- A chip is one target: an amount chip opens an editor, it never ticks.
- The header's `Today's dailies · n/total` counts day-cadence items only; weekly and monthly tasks get their own counts; only a real tick today celebrates.
- All calculations pure, `now` explicit. Commits: one coherent change each, green, pathspec, one command per Bash call.
## Review Focus
1. **A task record a later version wrote** (unknown measure) — hidden, never removed from storage, and survives export/import.
2. **An amount typed as `1,900`, `1900.5`, empty or negative** — amounts are whole numbers: separators are stripped, decimals rounded, empty or unreadable is mid-edit (Save disabled), negative refused.
3. **A monthly task with start day 31 in a 30-day month** — period boundaries from `periodOf`'s clamping; the amount key is the clamped start.
4. **Importing an older backup onto a device whose amount for the same week is newer** — the newer value stays.
5. **A task on a game the reader has hidden or isn't focused on** — not shown; its data untouched.
## Tasks
Each task: write the named tests first and watch them fail, implement, run `bun test` + `bun run typecheck`, commit by pathspec.
### Task 1: Amount goals and month scope in the goal model
**Files:** `src/shared/goal.ts`, `test/goal.test.ts`
**Produces:**
- `GoalSchema` adds `{ period: "week", measure: "amount", target: number (>0, ≤1e9), weekStartsOn }` and `{ period: "event", measure: "amount", target }`.
- `type AmountLog = Record<string /*periodStart*/, { value: number; at: string }>`.
- `GoalStatus` gains `measure: "days" | "amount"`, `periodStart: string`, and `scope: "week" | "month" | "event"`.
- `goalStatus` input gains `amounts?: AmountLog`. For amount: `count = amounts[periodStart]?.value ?? 0`, `required = target`, `open` = remaining days in scope (null for an unknown-end event), `shortfall = false`. `periodStart` = the week's start, or the event's first game-day for event scope.
- Copy: `goalCaption` amount → `1,900 of 2,100 this week · 200 to go`, met → `2,100 of 2,100 · Done this week`; `goalCount` amount → `1,900/2,100` (+` wk`/` mo`); month scope → `this month` / `mo` / `done this month`. Numbers via `toLocaleString("en-US")`.
- `goalRequired(goal): number` (required or target) for callers that showed `goal.required`.
**Tests:** amount week counts the stored value for this week only; last week's value ignored; met at ≥ target; event-scope amount keyed by first game-day; captions/count/label for amount and month; `readGoal` accepts amount shapes and still rejects `{measure:"amount"}` without target.
### Task 2: The task model
**Files:** create `src/shared/task.ts`, `test/task.test.ts`; modify `src/shared/custom.ts` (`RESERVED_ID_SEGMENTS` += `"mytask"`, `"routine"`).
**Produces:**
- `TaskId = z.string().regex(/^mytask:[a-z0-9]{6,32}$/)`, `mintTaskId(random = Math.random): string`.
- `MeasureSchema` = `{ kind: "days", required: 1–31 }` | `{ kind: "amount", target: >0 ≤1e9, unit?: string ≤24 }`.
- `TaskSchema` = `{ id, game: z.string().min(1), title: 1–80 trimmed, period: PeriodSchema, measure, from?: string, at, updatedAt }`, `type Task`.
- `taskStatus({ task, region, now, logged, amounts }): GoalStatus` — scope = `period.unit`; days: distinct ticks in `periodOf(...)` ≤ today; amount: value at the period start; `open` = period days ≥ today unticked (days) or remaining (amount); `required` for days capped at the period length.
- `taskCaption(task): string` — `every week from Monday · tick 1 day` / `every month from the 1st · reach 2,100 points`.
**Tests:** weekly days task done once this week; monthly amount task reads its month's key with 31→30 clamping; per-region boundary at reset (Genshin Europe); reserved segments include `mytask` (extend the existing `custom.test.ts` loop expectation if needed); caption strings.
### Task 3: Task and amount stores
**Files:** create `src/client/state/useTasks.ts`, `src/client/state/useAmounts.ts`, `test/tasks-store.test.ts`; modify `src/client/state/storage.ts` (KEYS `tasks`, `amounts`).
**Produces:**
- `mergeTasks(current: Record<string, unknown>, incoming: unknown): Record<string, unknown>` — validates incoming with `TaskSchema`, skips bad ones, keeps current records (even unreadable), later `updatedAt` wins.
- `visibleTasks(raw: Record<string, unknown>): Task[]` — the parseable ones, sorted by game then title.
- `useTasks()` → `{ tasks: Task[], addTask(draft), editTask(id, draft), removeTask(id), merge(incoming: unknown) }` with `type TaskDraft = { game, title, period, measure }`.
- `mergeAmounts(current: AmountLogMap, incoming: unknown): AmountLogMap` — per item and period, finite numbers only, later `at` wins, never removes.
- `useAmounts()` → `{ amounts: AmountLogMap, amountsFor(key): AmountLog, setAmount(key, periodStart, value: number | undefined), merge(incoming: unknown) }`. `setAmount(undefined)` removes that one period's entry (reader's own action).
**Tests:** unreadable stored task kept by merge and hidden by `visibleTasks`; merge later-wins both directions; amounts merge per period (older backup cannot roll back a newer week; a period only on one side survives; strings/NaN skipped).
### Task 4: Tasks and amounts in export and import
**Files:** `src/client/state/export.ts`, `src/client/App.tsx`, `test/progress.test.ts`.
**Produces:** `ExportData.tasks?`, `ExportData.amounts?`; `buildExportData(..., own: { games, events, tasks?, amounts? }, prefs?)` writes them when given; `ParsedImport.tasks: unknown`, `ParsedImport.amounts: unknown`; `importProgress` gains `mergeExtra(tasks, amounts)` before `restorePrefs`.
**Tests:** round trip keeps tasks (including an unreadable one) and amounts; an export without the keys parses with both `undefined`.
### Task 5: Tasks and amounts in the Dailies strip
**Files:** `src/client/components/Dailies.tsx`, create `src/client/components/AmountEditor.tsx`, `src/client/App.tsx`, `test/views.test.tsx`.
**Produces:**
- `DailyItem` gains `cadence: "day" | "week" | "month"` and `period?: Period` (tasks). Chores and events are `"day"`.
- `dailyGroups(..., goalFor, tasks: readonly Task[] = [], taskStatusFor: (t: Task) => GoalStatus = …)` appends a lane's tasks after its events, **only for lanes in `games`** (no stragglers: a hidden game's tasks stay hidden).
- `dailyTally` returns `{ total, complete, tickedToday, week: { total, complete }, month: { total, complete } }`, where `total/complete` count `"day"` items only.
- An item whose `goal.measure === "amount"` renders as an amount chip: label, `1,900/2,100`, a 2px underline in the hue at `count/required` width; click opens `AmountEditor` under the strip (`<label>{title} this week: <input> / 2,100 <unit>` · Save · Cancel; Enter saves, Escape cancels; empty or invalid disables Save; negative refused; integers).
- Header: `Today's dailies · n/total` then, when present, `· Weekly a/b · Monthly c/d`.
- Catch up: a days task gets `catchUpDays` pips as a chore does; an amount task gets an `AmountEditor` for the previous period.
- `Dailies` props: `tasks?`, `taskStatusFor?`, `onSetAmount?: (key, periodStart, value) => void`, `amountFor?: (key, periodStart) => number | undefined`.
**Tests:** task placement after events; hidden-lane tasks absent; separate counts; amount chip renders `1,900/2,100` with no `aria-pressed`; `chipLabel` for amount and month (`…, 1,900 of 2,100 this week`, `…, done this month`); `AmountEditor` static render with value and target.
### Task 6: Amount goals on events in the detail sheet
**Files:** `src/client/components/GoalControl.tsx`, `src/client/components/EventDetail.tsx`, `src/client/components/DailyChecklist.tsx` (none if the caption already handles amount), `src/client/App.tsx`, `test/views.test.tsx`, `test/custom-ui.test.tsx`.
**Produces:** `GoalControl` shows, once a goal is set, a measure toggle **Days · Points**; Points shows `[2100] points each week` / `in total` with its own draft field. `chooseGoal` keeps the measure when switching period. `EventDetail` gets `amount?: number`, `onSetAmount(id, periodStart, value)`, and renders an `AmountEditor` (always open, inline) under the goal control for an amount goal.
**Tests:** `chooseGoal` keeps measure; switching Days→Points carries a sensible default target (100); sheet renders the editor with the current value.
### Task 7: Your tasks in settings
**Files:** create `src/client/components/TaskForm.tsx`; modify `src/client/components/YourOwn.tsx`, `src/client/components/Controls.tsx` only if props pass-through needs it, `src/client/App.tsx`, `test/custom-ui.test.tsx`.
**Produces:** "Your weekly and monthly tasks" list in the Your own group (game short name, title, `taskCaption`, Edit, Delete) and **Add a task**. `TaskForm`: game (select of lanes by name), title, Every week (weekday select, default Monday) / Every month (day 1–31, default 1), Tick it off (`[1]` days) / Reach a number (`[target]` + optional unit). Save disabled until valid against `TaskSchema`.
**Tests:** form renders defaults; list shows caption; empty state shows the add button.
### Task 8: Docs and a look
PRD new feature entry (F17 — weekly and monthly tasks); DATA-MODEL keys, shapes, merge rules, reserved segments, export keys; AGENTS.md § Events the reader entered themselves (tasks join the reader's spaces; task store never drops on load); DESIGN.md amount chip + editor; spec status; recurring-events spec Phase C pointer. Build and screenshot the strip with a weekly amount task and a monthly tick task, and the settings form, dark and light, on a fresh port.