# ReKnock Recurring Tasks: Design

*Mockups: open [mockups.html](mockups.html). Research: [RESEARCH.md](research.html). Build prompt: [CLAUDE_CODE_PROMPT.md](prompt.html).*

## 1. Core idea

A recurring task is **one ordinary ReKnock task with a `repeat` rule attached**. When it is done, it goes quiet (future `start_date`) and wakes itself up on the next occurrence. No new "sleeping" state is invented: ReKnock already stays quiet until `start_date`.

```
 awake, nagging ──mark_done──▶ history snapshot saved
       ▲                              │
       │                       compute next occurrence
       │                              ▼
  start_date reached ◀── asleep (start_date = next - lead_days)
```

## 2. The repeat rule

Stored as one nullable JSON field `repeat` on the task (null = not recurring, which is every existing task).

| Field | Meaning | Example |
|---|---|---|
| `rrule` | RFC 5545 subset string | `FREQ=MONTHLY;BYMONTHDAY=28` |
| `mode` | `fixed` or `after_completion` | `fixed` |
| `lead_days` | Start knocking this many days early | `3` |
| `due_on_occurrence` | Keep `due_date` equal to the occurrence | `true` |
| `count_left` | Occurrences remaining, if `COUNT` used | `10` |
| `status` | `active`, `ended`, `stopped` | `active` |
| `anchor` | First occurrence date (needed for "every 2 weeks") | `2026-11-28` |

Plus a queryable column `next_occurrence` (a date, Toronto calendar).

### Supported RRULE subset

- `FREQ` = DAILY, WEEKLY, MONTHLY, YEARLY
- `INTERVAL` = N
- `BYDAY`: weekly `MO,TH`; monthly with ordinal `-1FR` (last Friday), `2TU`
- `BYMONTHDAY`: `1..31` or `-1` (last day); `BYMONTH` for yearly
- `UNTIL` (date, inclusive), `COUNT`
- Anything else is rejected with a friendly error. No `BYSETPOS`, `BYHOUR`, `RDATE`.

### Deliberate deviations from RFC 5545

- **Short months clamp, they don't skip.** `BYMONTHDAY=31` gives Feb 28 or 29, Apr 30, and so on. Always computed from the anchor day, so it never drifts (Jan 31, Feb 28, Mar 31).
- **Yearly Feb 29** gives Feb 28 in non-leap years.
- **5th weekday** ("5th Friday") falls back to the last such weekday.

### After-completion mode

Only `FREQ`+`INTERVAL` (days, weeks, months, years). Next = **completion date (Toronto)** + interval, using calendar math with month-end clamping (Jan 31 + 1 month = Feb 28/29). No `BYDAY`, no `BYMONTHDAY`. Rejected with a clear message if combined.

## 3. Lifecycle rules

**Creating.** First occurrence is the first of: the given `due_date`; the given `start_date`; the first rule match on or after today (after-completion: today). If `due_on_occurrence` is true, `due_date` = occurrence. Default for that flag: true when the interval is 7 days or longer (monthly, yearly, "every 30 days"), false for daily and weekly habits (so garbage night isn't permanently "urgent"). `start_date` = occurrence − `lead_days`, but never earlier than today.

**Completing (`mark_done`).**
1. Save a done-history snapshot (task text, contexts, quadrant, completed time, occurrence date, `series_id`, previous state for undo).
2. If the task is still asleep (`start_date` in the future), do **not** complete again. Reply "Already done for this round, next one is Nov 28. Say skip to jump ahead." This also makes double-taps harmless.
3. Next occurrence: **fixed** = first occurrence strictly after `max(current occurrence, today)`. **After-completion** = today + interval.
4. If `UNTIL` is passed or `COUNT` is exhausted, or the caller said `stop_series`, the series ends: the task is completed as a normal task, `status = ended`.
5. Otherwise set `next_occurrence`, `start_date = next − lead_days`, `due_date = next` (if `due_on_occurrence`), reset the context-only "unoffered" counter, keep text, contexts, nag times, quadrant, blocked_by.

**No backlog.** Missed occurrences are never created. If the Aug 28 payment was never done and today is Nov 13, finishing it sets the next one to Nov 28. History records how many were collapsed.

**Early completion.** Allowed once awake (inside the lead window). Done on Sep 25 for the Sep 28 alarm test gives next Sep 28 *next year*.

**Skip (`skip_occurrence`).** Same as done for scheduling and for `COUNT`, but history says "skipped" and it is not counted as a completion. Works on awake and asleep tasks (on an asleep task it skips the upcoming one).

**Stop.** Three ways: `mark_done(stop_series: true)` ("done, and no more"), `update_task(repeat: "none")` (turn it into a normal one-off, keeps current dates), or reaching the end condition.

**Undo.** `restore_task` on a recurring history snapshot rewinds the live task to the saved state, if it hasn't been completed again since.

**Editing.** Changing the rule recomputes `next_occurrence` from today. Changing `due_date` on a recurring task is a one-off override for the current occurrence only.

## 4. Interactions

| Area | Behavior |
|---|---|
| **Quadrants** | Kept across occurrences. Q1 nags every 90 min 08:00–21:00 from `start_date`; Q2 daily; Q3 batched; Q4 never nags. |
| **Due dates / urgency** | `due_date` = occurrence, so "within 2 days is urgent" works as before. Lead days start nagging earlier, urgency kicks in on its own. |
| **Dependencies** | `blocked_by` links to open tasks are kept. Links to done tasks are dropped on revival. A recurring blocker only blocks while it is awake (start_date reached and not done). Cycles still rejected. |
| **21-day Someday archive** | Tasks with `repeat.status = active` are **exempt**, including while asleep and while awake in Q4. Archive only after the series ends or is stopped. |
| **Context-only (3 days unoffered → 09:00)** | The counter resets on revival. Counting starts at `start_date`, never while asleep. |
| **Nag times** | Wall-clock `HH:MM` in Toronto, so DST doesn't shift them. |

## 5. Time and DST

- "Dates" are **America/Toronto calendar dates**. "Today" is `now` converted to Toronto.
- All date math is calendar math on `date` objects, **never** `+ 24h` on timestamps.
- Nag times are wall-clock. A 02:30 nag on spring-forward day (2026-03-08) fires once at the first valid time after the gap (03:00); on fall-back day (2026-11-01) a 01:30 nag fires once, not twice.
- A completion at 23:30 counts for that Toronto day, even though it is the next day in UTC.

## 6. MCP changes

Descriptions are written to be read aloud.

**`add_task` / `update_task` get `repeat`** (any one of):
- Structured object:
  ```json
  {"freq":"monthly","month_day":28,"mode":"fixed","lead_days":3,
   "until":"2027-12-31","count":12}
  ```
  Keys: `freq` (daily|weekly|monthly|yearly), `interval`, `weekdays` (`["mon","thu"]`), `month_day` (1–31 or -1), `nth_weekday` (`{"n":-1,"weekday":"fri"}`), `month`, `mode`, `lead_days`, `until`, `count`, `due_on_occurrence`.
- `rrule` string (advanced).
- `"none"` to stop repeating.

**`repeat_text`** (friendly helper, plain English, parsed by a deterministic grammar):

| Say | Becomes |
|---|---|
| "every month on the 28th, start 3 days early" | monthly, day 28, lead 3 |
| "last Friday of every month" | monthly, -1 FR |
| "every September 28" | yearly, 9/28 |
| "every Tuesday and Thursday" | weekly, TU,TH |
| "every 2 weeks" | weekly, interval 2 |
| "every 30 days after I finish it" | daily interval 30, after_completion |
| "every day", "every weekday" | daily; weekly MO–FR |
| "... until June 2027", "... 10 times" | until / count |

The reply always echoes the interpretation: *"Got it: repeats monthly on the 28th, knocks 3 days early. Next one is Nov 28, starting Nov 25."* If it can't parse, it says what it didn't understand and gives 3 examples. It never guesses silently.

**`list_tasks`**: each recurring task shows `repeat` (summary text, mode, status), `next_occurrence`, and `waiting: true` when asleep. New filter `recurring: true|false`. Asleep recurring tasks stay findable.

**`skip_occurrence(id)`**: "Skip this round of a repeating task. It comes back at the next scheduled time."

**`mark_done`**: new optional `stop_series` ("Mark done and stop it repeating for good").

**`list_done`**: recurring completions appear as normal entries, marked repeating, with `skipped: true` where relevant.

**`matrix_summary` / `weekly_review_summary`**: count recurring tasks asleep separately so the matrix isn't confusing.

## 7. Email

**Daily summary** gets a "Coming up (next 7 days)" block, sorted by date, only for asleep or lead-window recurring tasks:

> Coming up: Pay Walmart Mastercard, Sat Nov 28 (knocks from Nov 25) · Garbage night, Tue

**Weekly review** gets "Repeating tasks": next 30 days of occurrences, completions this week, skipped this week, overdue repeaters (awake and past occurrence), and series that ended.

## 8. UI (adapt to the existing ReKnock screens)

See [mockups.html](mockups.html) for sketches.

- **Task editor:** a "Repeat" row below Due date. Tap opens a sheet: *Doesn't repeat / Daily / Weekly / Monthly / Yearly / Custom*. Weekly shows 7 weekday chips. Monthly offers "on day 28" or "on the last Friday". A switch "Repeat from: Schedule | When I finish". "Start knocking __ days early" stepper. "Ends: Never / On date / After N times". Under it a live preview line: *Next: Sat Nov 28*.
- **Task list:** a small ↻ badge and "Next Nov 28" for asleep ones. Asleep recurring tasks show in a collapsible "Waiting" group, dimmed.
- **Task detail:** buttons **Done**, **Skip this one**, **Stop repeating**.
- **Done history:** entries carry the ↻ badge, skipped ones struck through.
- If ReKnock has no editor UI beyond MCP, ship MCP + email only and say so.

## 9. Migration

Additive only: nullable `repeat` JSON, nullable `next_occurrence` date, nullable `series_id` on done-history rows. Idempotent. No backfill. Existing tasks have `repeat = null` and take exactly the old code paths. The full existing test suite must pass unmodified.

## 10. Tests (with expected values)

Use an injectable clock; tests run with `America/Toronto`.

**Month-end**
- Monthly day 31 from 2026-01-31: Feb 28, Mar 31, Apr 30, May 31.
- Same in leap year: 2028-01-31 → 2028-02-29.
- Monthly day 30 from Jan 30 2027 → Feb 28 2027; then Mar 30.
- `BYMONTHDAY=-1` from Feb 2026 → Mar 31.
- Yearly Feb 29 from 2024-02-29: 2025-02-28, 2026-02-28, 2027-02-28, 2028-02-29.
- After-completion 1 month: done Jan 31 2026 → Feb 28; done Jan 30 2028 → Feb 29 2028.
- Every 2 months anchored Dec 31 → Feb 28, Apr 30 (no drift).
- 5th Friday monthly: months without one use the last Friday.

**Ordinal weekdays**
- Last Friday, done 2026-11-13: Nov 27, then Dec 25, then Jan 29 2027.

**Yearly (smoke alarm)**
- Sep 28, lead 3. Created 2026-11-13 → next 2027-09-28, start 2027-09-25.
- Done Sep 25 2027 (early) → 2028-09-28. Done Oct 5 2027 (late) → 2028-09-28.

**No backlog**
- Monthly 28th, occurrence Aug 28, today Nov 13, mark done → next Nov 28 (not Sep 28). History notes 2 collapsed.
- Done on Nov 28 itself → Dec 28. Done Nov 29 → Dec 28.
- Weekly Tue/Thu, done Fri Nov 13 → Tue Nov 17.
- Daily habit missed 5 days, then done → tomorrow only.
- After-completion 30 days, done 3 months late → today + 30.

**DST (Toronto)**
- Daily task done 2026-03-08 23:30 local (spring-forward day) → next 2026-03-09, no off-by-one from the 23-hour day.
- Done 2026-11-01 23:30 (fall-back, 25-hour day) → 2026-11-02.
- Done at 2026-11-02 00:30 Toronto (05:30 UTC) counts as Nov 2, not Nov 1.
- Nag at 02:30 on 2026-03-08 fires exactly once (03:00 or the existing policy); nag at 01:30 on 2026-11-01 fires exactly once.
- Weekly Sunday series across both transitions keeps the same weekday.

**Lifecycle**
- `COUNT=3`: 3rd completion ends the series; no revival. Skip counts toward COUNT.
- `UNTIL` inclusive: next occurrence after `UNTIL` ends the series.
- Done on an asleep task returns the "already done" message and changes nothing.
- Skip on asleep task skips the upcoming one.
- `stop_series` and `repeat: "none"` behave as in §3.
- `restore_task` on a snapshot rewinds; fails politely if completed since.
- Due date shifts to the new occurrence; contexts, nag times, quadrant and `blocked_by` (to open tasks) survive.
- Q4 recurring is never archived at 21 days; a non-recurring Q4 still is.
- Recurring blocker: dependent is blocked only while the blocker is awake.

**Parser:** every row in the §6 table, plus rejects ("every blue moon") and `after_completion` + `BYDAY` conflict.

**Migration:** old rows load, `list_tasks` output for non-recurring tasks is byte-identical.

**Email:** daily summary and weekly review show upcoming recurring items; unchanged when there are none.

## 11. Decisions Rob may want to change

- Clamp short months (vs RFC skip).
- Recurring tasks are exempt from Someday archive.
- `due_on_occurrence` default off for daily/weekly.
- Done on an asleep task does nothing (vs. completing early).
- Single live row (vs. new row per occurrence).
