ReKnock Recurring Tasks: Design

Mockups: open mockups.html. Research: RESEARCH.md. Build prompt: CLAUDE_CODE_PROMPT.md.

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).

FieldMeaningExample
rruleRFC 5545 subset stringFREQ=MONTHLY;BYMONTHDAY=28
modefixed or after_completionfixed
lead_daysStart knocking this many days early3
due_on_occurrenceKeep due_date equal to the occurrencetrue
count_leftOccurrences remaining, if COUNT used10
statusactive, ended, stoppedactive
anchorFirst occurrence date (needed for "every 2 weeks")2026-11-28

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

Supported RRULE subset

Deliberate deviations from RFC 5545

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

AreaBehavior
QuadrantsKept across occurrences. Q1 nags every 90 min 08:00–21:00 from start_date; Q2 daily; Q3 batched; Q4 never nags.
Due dates / urgencydue_date = occurrence, so "within 2 days is urgent" works as before. Lead days start nagging earlier, urgency kicks in on its own.
Dependenciesblocked_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 archiveTasks 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 timesWall-clock HH:MM in Toronto, so DST doesn't shift them.

5. Time and DST

6. MCP changes

Descriptions are written to be read aloud.

add_task / update_task get repeat (any one of):

  {"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.

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

SayBecomes
"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 for sketches.

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

Ordinal weekdays

Yearly (smoke alarm)

No backlog

DST (Toronto)

Lifecycle

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