docs: settle check-in goals and specify weekly and monthly tasks

The goals draft stopped at three open questions; the owner answered them,
and widened the scope to weekly and monthly chores with numeric targets.
Both share one period engine, so they are specified together and built
goals-first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-13 12:57:37 +02:00
co-authored by Claude Opus 5.5
parent 84ea00c059
commit 1deb0cb4cb
2 changed files with 332 additions and 128 deletions
@@ -1,152 +1,204 @@
# Event check-in goals
**Date:** 2026-10-03
**Date:** 2026-10-03, revised and approved 2026-10-13
**Status:** proposed; not implemented
**Status:** approved; spec 1 of 2. Spec 2 is
`2026-10-13-periodic-tasks-design.md`, which reuses the period engine defined here.
## Request and intended outcome
Some events require a fixed number of check-ins rather than participation every day.
The owner's example is Genshin's “To Temper Thyself and Journey Far”: five completions
out of seven days in a week. This example is the requested use case, not a verified
source schedule or a rule to hardcode by title.
out of seven days in a week. The event must stay in Dailies while unfinished, show how
many check-ins the week needs, and distinguish meeting this week's requirement from
finishing the event.
An unfinished event must remain available in Dailies. The tracker should show how many
check-ins are required, count the reader's recorded days, and distinguish meeting this
week's requirement from completing the whole event.
Checked against the feed: the event runs Monday 2026-08-10 to Monday 2026-11-02, twelve
whole weeks, and Game8 describes it as “Daily and Weekly Training Goals”. Genshin's week
rolls on Monday at the server's daily reset. So weeks that start on a chosen weekday at
the game's reset fit it. The required count is not published in our sources, which is
one reason a goal is reader input rather than parsed.
## Current behavior and the gap
## Decisions taken
`src/shared/daily.ts` detects whether an event repeats and counts recorded game-days,
remaining opportunities, missed days and streaks. It has no required-count or weekly
goal. `DailyChecklist.tsx` reports recorded days against the event's whole window,
which cannot express “five of seven this week.”
`progress.daily` already lets the reader put an event in Dailies when source wording
does not identify it. `App.tsx` supplies live, outstanding repeating events to
`Dailies.tsx`; explicitly done or ignored events are excluded. A weekly goal must fit
this flow without treating a satisfied week as a permanent event completion.
## Proposed scope
Let the reader configure an optional check-in goal in the event detail sheet:
- **Across this event:** a required number of distinct game-days in the event window.
- **Each week:** a required number of distinct game-days in each seven-day period,
with an explicitly chosen week-start day.
The weekly mode covers the reported example. The event-wide mode covers login campaigns
that require a smaller number of days than the full time available. Neither mode
requires consecutive days; streaks remain a separate fact.
Setting a goal makes the event eligible for Dailies even with experimental detection
off. Removing the goal returns to the existing detection and override behavior. The
existing “this isn't a daily event” action must offer to remove the goal too, rather
than leave a conflicting configuration hidden in storage.
Goals are reader input in this first version. No parser changes, inferred counts,
title-specific exceptions, or changes to `GachaEvent` are proposed.
## Counting and period boundaries
Count at most one check-in per game-day, using the existing recorded day keys and the
event's game and selected server region. All calculations take `now` explicitly.
Future days never count toward a goal.
A weekly period starts on the chosen weekday at the game's server reset, not browser
midnight or UTC midnight. Periods are seven game-days, intersected with the event's
actual start and end. An event-wide goal uses the event's resolved window, including
the existing handling of day-precision dates and region-specific ends.
Ticks outside the applicable window do not satisfy its goal, but remain stored and
editable through the existing history controls. Catch-up and corrections recalculate
the affected period; no week transition clears, rewrites or prunes recorded days.
A shortened first or last week retains the stated target. If fewer opportunities
remain than check-ins needed, show that fact without lowering the target or inventing
extra days. An unknown event end remains unknown; a configured weekly rule can still
define the current week's boundary without claiming an event end.
## Dailies and completion behavior
| State | Dailies behavior |
| Question | Answer |
|---|---|
| Weekly goal unfinished | Keep the event visible; show, for example, `3 / 5 this week` and today's tick |
| Weekly goal met | Keep the unfinished event visible as `5 / 5 · Done this week`; stop presenting further check-ins as required |
| Next week opens | Recalculate against the new period and offer today's check-in again |
| Event-wide goal met | Show `5 / 5 · Goal reached`, with an explicit action to mark the event done |
| Event marked done or ignored | Exclude it from Dailies under the existing outstanding rule |
| Event expired | Follow the existing live-event rule; retain its goal and recorded history |
| Who sets a goal? | The reader, per event, in the detail sheet. Nothing is inferred from a title or source |
| Modes | **Each week**: N distinct game-days per week. **Across the event**: N distinct game-days in the whole window |
| Week boundary | A chosen ISO weekday, at the game's server reset for the reader's region. Defaults to the weekday of the event's first game-day |
| Numeric targets (`1900 / 2100`) | The goal carries `measure` from day one. This spec ships `measure: "days"` only; spec 2 adds `"amount"` and its store |
| Does meeting a goal finish the event? | Never. Only the reader marks an event done |
| Does a met week stay in Dailies? | Yes, as done-this-week, until the reader marks the event done |
| `GachaEvent`, IDs, day-log keys, storage keys | Unchanged |
A goal never writes `status: "done"` automatically. Reaching a check-in count might
not finish other objectives or claim the rewards. Only the reader's explicit event
completion removes a live event from Dailies. Undoing completion brings it back with
its goal and ticks intact.
## The period engine — `src/shared/period.ts`
The detail checklist keeps past days editable, including after a target is reached.
Undoing a tick can make a goal unfinished again. For goals, describe remaining
opportunities and check-ins needed; do not label every unticked past day as a failure
when participation on every day was never required.
Pure, takes `now` explicitly, and works in **day-key space** like `streakOf` and
`catchUpDays`: every day is a `YYYY-MM-DD` key already cut on the game's reset clock by
`dayKey`, so period boundaries inherit each game's per-region reset with no new clock.
Dailies' daily completion count and celebration must treat a satisfied goal as having
no further work required today. This must not fabricate a tick for today. A real tick
and a satisfied period remain separate states in accessible labels and rendering.
```ts
type Period =
| { unit: "week"; startsOn: 1 | 2 | 3 | 4 | 5 | 6 | 7 } // ISO weekday
| { unit: "month"; startsOn: number }; // 1–31, used by spec 2
## Storage and implementation boundaries
/** The period containing `today`, as its first key and its day keys, oldest first. */
periodOf(period: Period, today: string): { start: string; days: string[] };
```
Store an optional goal alongside the reader's per-event progress. The proposed shape
is a discriminated Zod schema with a positive integer `required` and either
`period: "event"` or `period: "week"` plus `weekStartsOn` (ISO weekday 1–7). Weekly
targets are limited to 1–7; event-wide targets must not exceed a known opportunity
count at configuration time. Existing goals remain readable if source dates later
change, with any resulting shortfall disclosed rather than silently discarded.
- **Week:** the ISO weekday of a key is its UTC weekday (keys are dates, not instants).
Step back `(weekday − startsOn + 7) % 7` days from `today`; take seven keys.
- **Month** (spec 2's consumer, built and tested here because it is the same function):
the most recent key whose day-of-month is `startsOn`, clamped to the month's last day
when the month is shorter (31 → 30 Sep, 28/29 Feb). The period runs to the day before
the next start.
- Callers get `today` from `dayKey(now, region, game)`, so “immediately before reset”
and “at reset” fall in different periods exactly where the game's day rolls.
Missing goal means today's behavior. No existing event ID, daily-log key, game-day
format, reset mapping or localStorage key changes. Preserve a progress record that
contains only a goal; include the new field in emptiness checks and export/import.
Import follows the existing last-edited progress merge and daily-log union rules.
Validate the goal without deleting unrelated status, notes or recorded ticks when
an imported goal is invalid.
## Goals — `src/shared/goal.ts`
Keep goal calculations pure in shared code. Wire the result into `App.tsx`,
`Dailies.tsx`, `DailyChecklist.tsx`, and `EventDetail.tsx`; extend the progress store
and backup validation. Reuse the shipped visual tokens and existing detail controls.
Custom event occurrence IDs keep their existing meaning; a goal applies to the
selected occurrence unless applying it to an entire recurring rule is separately
specified.
Zod is the source of truth for the type:
```ts
// A plain union, not discriminatedUnion("period"): spec 2 adds amount variants that
// share a period with these, so no single field discriminates.
const GoalSchema = z.union([
z.object({ period: z.literal("week"), measure: z.literal("days"),
required: z.number().int().min(1).max(7),
weekStartsOn: z.number().int().min(1).max(7) }),
z.object({ period: z.literal("event"), measure: z.literal("days"),
required: z.number().int().min(1).max(366) }),
]);
type Goal = z.infer<typeof GoalSchema>;
/** A stored value as a Goal, or undefined when absent or unreadable. Never throws. */
readGoal(stored: unknown): Goal | undefined;
goalStatus(input: {
goal: Goal; startsMs: number; endsMs: number | null;
region: Region; game: LaneId; now: number; logged: readonly string[];
}): GoalStatus;
interface GoalStatus {
scope: "week" | "event";
required: number;
count: number; // distinct ticked keys in scope, never later than today
met: boolean; // count >= required
needed: number; // max(0, required - count)
open: number | null; // unticked days in scope from today on; null when unknowable
shortfall: boolean; // open !== null && needed > open
}
```
Counting rules:
- **Scope.** Week: `periodOf` for today, intersected with the event's days — `dailyDays`
when the end is known, otherwise every key from the event's first game-day. Event:
`dailyDays`; with an unknown end, every key from the first game-day to today, and
`open` is null rather than a guess.
- **One per game-day**, from the existing day log. A key later than today never counts.
A tick outside scope never counts and is never removed.
- **A short first or last week keeps its target.** If too few days remain, `shortfall`
says so; the target is not lowered and no day is invented.
- Catch-up and corrections need no special handling: status is recomputed from the log on
every render, so backfilling a previous week cannot move this week's count.
**Validated where used, never stripped from storage.** `readGoal` returns undefined for a
missing or unreadable goal and the record is left exactly as stored. Dropping an invalid
field on load would delete it from the device on the next write — the hazard AGENTS.md
§ Events the reader entered themselves describes — and the likeliest unreadable goal is a
newer shape (spec 2's `amount`) read by an older cached bundle. Setting a goal in the UI
overwrites whatever was there.
## Storage
`Progress` gains `goal?: unknown` in storage, read through `readGoal`. Concretely:
- `isEmpty` treats a present `goal` as recorded, so a goal-only record survives.
- Export and import need no new key: goals ride `progress`, and `mergeProgress` already
keeps the later-edited record whole. Old backups lack the field and remain valid.
- No ID, day-log key, game-day format, reset mapping or localStorage key changes.
## Dailies eligibility
- An event whose `readGoal` is defined is a daily, whatever detection or `progress.daily`
says. App computes `daily = goal !== undefined || resolveDaily(...)`.
- **Setting a goal** clears a stored `daily: false` (the two would contradict) and leaves
any other `daily` value alone, so removing the goal later restores exactly the earlier
detection and override behaviour.
- **“This isn't a daily event”** clears the goal in the same patch, and its confirmation
copy says so when a goal is set. No conflicting state is left hidden in storage.
- `outstanding` is unchanged: done or ignored events leave the strip regardless of display
preferences; undoing either restores the event with its goal and ticks.
## What the reader sees
**Detail sheet — “Check-in goal”** under the daily checklist, and offered on non-daily
events too (setting one makes the event a daily). Three choices in the style of the
existing status and effort controls: **Off** · **Each week** `[5] days, weeks start
[Monday]` · **Across the event** `[5] days`. Weekly `required` is 1–7; event-wide is
capped at the known number of days when the end is known.
**Checklist caption** when a goal is set replaces the “missed” wording, because an
unticked past day is not a failure when every day was never required:
- Weekly: `3 of 5 this week · 2 needed · 4 days left this week`
- Event-wide: `3 of 5 · 2 needed · 9 days left`, or `3 of 5 · 2 needed` with an unknown end
- Shortfall: `2 needed, 1 day left this week`
- Met: `5 of 5 · Done this week` / `5 of 5 · Goal reached`. An event-wide goal reached
shows **Mark event done** beside it, which uses the existing status control.
Past days stay editable after a target is reached; removing a tick can unmeet it.
**Dailies chip.** Gains a short count: `3/5 wk` or `3/5`. Tapping still ticks today.
Three visually distinct states:
| State | Look | Accessible label suffix |
|---|---|---|
| Not done today, goal unmet | Outstanding chip as now, plus count | `, not done today, 3 of 5 this week` |
| Goal met, not ticked today | Full-strength hue border and check, outstanding background | `, done this week` / `, goal reached` |
| Ticked today | Done chip as now, plus count | `, done today` |
**Header count and celebration.** In `Today's dailies · n/total`, an item is complete when
ticked today **or** its goal is met — no tick is fabricated. The burst fires only when the
number of items **ticked today** rises and everything is complete, so meeting a week by
backfilling an earlier day completes the strip quietly, as backfilling does today.
## Testing
- `period.ts`: week starts for every `startsOn`, across month and year ends; month starts
with clamping; Genshin keys immediately before and at reset for Asia, America and
Europe, and a game with a reset override (`resetHourLocal`).
- `goal.ts`: acceptance cases 1–7 below as pure tests, including future ticks, ticks
outside scope, partial first and last weeks, shortfall and unknown ends; `readGoal` on
missing, malformed and future-shaped values.
- Progress: goal-only records survive `patch`; `mergeProgress` keeps the later record's
goal; export → import round trip keeps goals; an old export imports unchanged.
- Dailies: `dailyGroups`/item completion with goals; the burst rule; labels.
- Full offline suite and typecheck; inspect the built page in headless Chrome.
## Acceptance checks
1. A configured five-per-week event appears in Dailies with detection off and no
explicit completion. At three distinct ticks it reads `3 / 5 this week`.
2. Five nonconsecutive days satisfy the week. The event remains visible, labelled
done this week, without being marked done or requesting a sixth check-in.
3. The next weekly boundary makes the goal available again; previous ticks remain
unchanged. Test immediately before and at reset for each supported region.
4. Backfilling a previous week never advances this week's count. Removing a current
tick below the target restores the unfinished state.
5. A five-check-in event-wide goal in a seven-day window reaches its target on the
fifth recorded day and waits for explicit event completion.
6. Done and ignored events remain excluded regardless of list-display preferences;
undoing either restores the recorded goal and days while the event is live.
7. Partial weeks, unknown ends, future ticks, region-specific boundaries and games
with reset overrides count correctly without invented dates or deleted history.
8. Goal-only progress survives reload and export/import. Old backups remain valid;
invalid goals do not erase otherwise valid reader data.
9. Events with no goal retain current daily behavior, grouping, order and catch-up.
10. Accessible labels distinguish done today, done this week, and event completion.
1. A five-per-week event with detection off appears in Dailies; three distinct ticks read
`3/5 wk`.
2. Five non-consecutive days meet the week: still visible, labelled done this week, not
marked done, counted complete in the header.
3. The next week boundary offers the event again; earlier ticks are unchanged.
4. Backfilling a previous week never moves this week's count; removing a current tick
below target restores the unmet state.
5. A five-day event-wide goal in a seven-day window is reached on the fifth day and waits
for an explicit Mark event done.
6. Done and ignored events stay excluded; undoing either restores goal and ticks.
7. Partial weeks, unknown ends, future ticks, regional resets and reset overrides count
correctly with no invented date and no deleted history.
8. Goal-only progress survives reload and export/import; old backups import; an
unreadable goal leaves the record intact and behaves as no goal.
9. Events with no goal behave exactly as before.
10. Accessible labels distinguish done today, done this week and goal reached.
Before shipping, update PRD F12 and the data-model sections for progress, daily
checklists and export/import. Update the design record for any new visible controls.
Run relevant unit tests, the full offline suite and typecheck, and inspect the built
interface in a browser.
## Docs that change with it
## Decisions to confirm before implementation
- Verify the example's actual weekly reset and whether its requirement is per week
or per another in-game training period. Do not infer this from the event title.
- Confirm that choosing a week-start weekday covers the desired games. If a source
resets on another clock or uses event-anchored seven-day periods, specify that
boundary explicitly before supporting it.
- Confirm the proposed event-wide mode and the choice to retain satisfied goals in
Dailies until the reader marks the whole event done.
PRD F12 (check-in goals paragraph); DATA-MODEL § Client-side storage `progress` table and
key comment, § Daily checklists, § Export format; AGENTS.md § Events that repeat daily (a
goal makes an event a daily; meeting one never marks it done); DESIGN.md for the
goal-met chip state and the goal control.
@@ -0,0 +1,152 @@
# Weekly and monthly tasks, with numeric targets
**Date:** 2026-10-13
**Status:** approved; spec 2 of 2. Builds on `2026-10-03-check-in-goals-design.md` (the
period engine and goals) and ships after it, in two phases.
## Request and intended outcome
Games have chores that roll weekly or monthly rather than daily — earning ZZZ's weekly
activity points, completing Endfield's weekly routine. The reader wants to set these up
per game, see them beside the dailies, and track either a plain “done this period” or a
number against a target: `1900 / 2100`.
The owner's direction on where they come from: **reader-authored as the base**; the app
can also ship known tasks per game; when the reader turns a game on, the app asks whether
to use that game's automatic weekly and monthly tasks, and a toggle governs it; and a
built-in task can be copied into the reader's own tasks to edit.
The recurring-events spec (2026-08-27) deferred “weekly task lists” to its Phase C. This
is that phase for the reader's side; it adds no `GachaEvent` field and nothing to ingest.
## Decisions taken
| Question | Answer |
|---|---|
| What is a task? | A per-game chore with a period (week or month) and a measure (days or amount). Not an event: no window, no end, no status |
| Measures | `days`: N distinct game-days ticked in the period (N = 1 is “done this period”). `amount`: a number the reader enters against a `target` |
| Where amounts live | A new store, one value per item per period |
| Where ticks live | The existing day log, keyed by the task id — union merge and export already work |
| Reader-authored ids | `mytask:<random>` |
| Built-in ids | `routine:<gameId>:<slug>` |
| Built-ins on by default? | No. Per-game opt-in, asked once when the game is turned on, and a toggle in settings |
| Editing a built-in | Not in place. “Make it mine” copies it to a reader task and hides the original |
| Amount goals on events | Yes: spec 1's goal gains `measure: "amount"` variants, using the same store and editor |
| Phasing | **2a** reader tasks, amounts, amount goals. **2b** built-in routines, opt-in prompt and toggle, make-it-mine |
## Model
`src/shared/task.ts`, Zod as source of truth:
```ts
const MeasureSchema = z.union([
z.object({ kind: z.literal("days"), required: z.number().int().min(1).max(31) }),
z.object({ kind: z.literal("amount"), target: z.number().positive(),
unit: z.string().max(24).optional() }), // "points", "activity"
]);
const TaskSchema = z.object({
id: z.string(), // mytask:<random>
game: z.string(), // LaneId; z.string() for the retirement rule
title: z.string().min(1).max(80),
period: PeriodSchema, // from period.ts: week+startsOn | month+startsOn
measure: MeasureSchema,
from: z.string().optional(), // routine id this was copied from (2b)
at: z.string(), updatedAt: z.string(),
});
```
`game` is `z.string()`, not `GameId`, for the reason `CustomEvent.game` is: narrowing it
arms every future game removal to delete reader rows on load. `days.required` is capped by
the period's length when edited (7 for a week).
`taskStatus({ task, region, now, logged, amounts })` returns `{ periodStart, count?,
value?, met, needed }` from `periodOf` and the stores. Pure; `now` explicit.
Spec 1's `GoalSchema` gains two variants: `{ period: "week", measure: "amount", target,
weekStartsOn }` and `{ period: "event", measure: "amount", target }`. An event-scoped
amount uses the event's first game-day as its period key.
## Storage
| Key | Shape | Merge on import |
|---|---|---|
| `gacha-tracker:v1:tasks` | `{ [mytask id]: Task }` | By id, later `updatedAt` wins; never removes |
| `gacha-tracker:v1:amounts` | `{ [itemKey]: { [periodStartKey]: { value, at } } }` | Per item **and** period, later `at` wins; never removes |
| `daily` (existing) | task ticks under the task id | Existing union of days |
| `prefs.routineGames` (2b) | `string[]` — games whose built-ins are on | Prefs rules as today |
| `prefs.routinesAsked` (2b) | `string[]` — games already asked about | Prefs rules as today |
- `itemKey` is a task id or an event id. A new period starts at no value; old periods are
never pruned. Deleting a task removes the task record only — its ticks and amounts stay,
as every log in this app does.
- Loading reads through a `validRecords`-style filter **for display only**; a task that
fails its schema is not shown but is not dropped from storage.
- `tasks` and `amounts` are additive export keys: older exports lack them and import
unchanged; `parseImportData` passes them through for validation by the stores.
- `mytask` and `routine` become reserved first segments alongside `dailies`, `mygame`,
`myevent`; the existing test that no reserved segment is a `GameId` covers them.
## What the reader sees
**Dailies strip.** Weekly and monthly items sit in their game's group, after that game's
dailies, keeping the rule that a game's items stay together and that grouping never
re-sorts.
- `days` chip: tap ticks today, like a daily. Label `Weekly boss · 1/1 wk`, or `3/5 wk`.
- `amount` chip: `ZZZ activity · 1900/2100`, with a thin progress underline in the hue.
A chip is one target, so tapping it **opens an inline editor under the strip** —
`Activity points this week: [1900] / 2100 · Save` — rather than ticking. Enter saves,
Escape cancels; the value is where the reader is now, not an increment.
- Met: the spec 1 met state, `2100/2100 · Done this week` / `· Done this month`.
- Header: `Today's dailies · 3/7` keeps counting dailies only; weekly and monthly items get
their own count beside it (`Weekly 1/2 · Monthly 0/1`, each shown only when present).
The celebration stays about today's dailies.
- Catch up: a `days` task shows its current and previous period's days as pips; an
`amount` task shows last period's value as an editable field.
- Accessible labels: `ZZZ activity points, 1900 of 2100 this week`, `…, done this week`.
**Amount goals on events.** The detail sheet's goal control gains “Points” beside days:
`[2100] points each week` or `in total`; the checklist area shows the value and the same
editor.
**Settings — “Your own”.** A tasks list beside your games and events: add, edit, delete.
Fields: game, title, every week (start weekday) or every month (start day), and either
“tick N days” or “reach a number” (target, optional unit). Weekly start defaults to
Monday; monthly to the 1st.
### Phase 2b — built-in routines
- `src/shared/routines.ts`: per-game templates with the same fields plus `source` (URL)
and `checked` (date). **Only a task a cited source documents ships**, the bar
`dailyTasks` in `games.ts` is held to; the owner's examples (ZZZ weekly activity, 2100;
Endfield weekly routine, 10) are the first candidates and are verified, not assumed.
- **Asking.** When a game with built-ins becomes visible — first-run picker, or switched on
in settings — and is not in `routinesAsked`, a one-line card at the top of the dailies
strip asks: `Track Zenless Zone Zero's weekly and monthly tasks? Yes · No thanks`. Either
answer records the game in `routinesAsked`; Yes also adds it to `routineGames`. Never a
modal, and never asked twice.
- **Toggle.** The games settings group shows a “Weekly tasks” switch on each game that has
built-ins, writing `routineGames`. Off hides the chips; their ticks and amounts stay.
- **Make it mine.** From a built-in chip's editor or the settings list: copies the template
into a `mytask:` record with `from` set, and copies the built-in's ticks and amounts to
the new id (additive; the originals stay). A built-in whose id is some task's `from` is
hidden; deleting that copy brings it back.
## Testing
- `task.ts`: status for days and amounts, week and month, at period boundaries per region.
- Stores: merge rules per table above; tasks failing schema are hidden, not dropped;
export/import round trip including old exports.
- Dailies: placement after a game's dailies, separate weekly/monthly count, editor
save/cancel, no celebration from weekly items.
- 2b: prompt asked once per game, toggle, make-it-mine copying and hiding, routine data has
a source on every entry (a test).
- Full suite, typecheck, browser inspection.
## Docs that change with it
PRD: a new feature entry for weekly and monthly tasks; DATA-MODEL: the two keys, the prefs
fields, reserved segments and export keys; AGENTS.md § Events the reader entered
themselves (tasks join the reader-authored spaces) and the dailies grouping rule; DESIGN.md
for the amount chip and editor; the 2026-08-27 spec's Phase C note pointing here.