# TaskRecurrence Recurrence engine that calculates the next due date when a recurring task is completed. --- ## Table of Contents 1. [Overview](#overview) 2. [Features](#features) 3. [Usage](#usage) 4. [API Reference](#api-reference) 5. [Recurrence Patterns](#recurrence-patterns) 6. [Completion Flow](#completion-flow) 7. [Related Documentation](#related-documentation) --- ## Overview `TaskRecurrence` is a pure functional module with no state or processes. Given a recurrence pattern string (e.g. `"daily"`, `"every monday,wednesday"`) and a reference timestamp, it computes the Unix timestamp for the next occurrence. The module is called by `NisTask.complete_recurring/3` to automatically spawn the next instance of a recurring task when the current one is completed. Recurring instances are independent — there's no parent-child chain between them. ## Features | Pattern | Description | Example | |---------|-------------|---------| | `hourly` | Every hour (+3600s) | Next hour same minute | | `daily` | Every day (+24h) | Tomorrow same time | | `weekly` | Every 7 days | Next week same time | | `monthly` | Same day next month | Jan 31 → Feb 28 (day clamped) | | `weekdays` | Mon-Fri, skipping weekends | Friday → Monday | | `every N seconds` | Fixed second interval | `every 10 seconds` → +10s | | `every N minutes` | Fixed minute interval | `every 5 minutes` → +300s | | `every N hours` | Fixed hour interval | `every 2 hours` → +7200s | | `every N days` | Fixed day interval | `every 3 days` → +72h | | `every N weeks` | Fixed week interval | `every 2 weeks` → +14 days | | Named days | Specific weekdays | `every monday,wednesday,friday` | ## Usage ### Calculate Next Due Date ```elixir now = :os.system_time(:second) # Sub-day intervals TaskRecurrence.next_due_at(now, "every 10 seconds") # => now + 10 TaskRecurrence.next_due_at(now, "every 5 minutes") # => now + 300 TaskRecurrence.next_due_at(now, "every 2 hours") # => now + 7200 TaskRecurrence.next_due_at(now, "hourly") # => now + 3600 # Daily: tomorrow TaskRecurrence.next_due_at(now, "daily") # => now + 86400 # Weekly: next week TaskRecurrence.next_due_at(now, "weekly") # => now + 604800 # Monthly: same day next month (with day clamping) TaskRecurrence.next_due_at(now, "monthly", "Asia/Ho_Chi_Minh") # Weekdays: skip weekends TaskRecurrence.next_due_at(now, "weekdays", "Asia/Ho_Chi_Minh") # Every 3 days TaskRecurrence.next_due_at(now, "every 3 days") # Specific days TaskRecurrence.next_due_at(now, "every monday,wednesday,friday", "Asia/Ho_Chi_Minh") ``` ### Validate a Pattern ```elixir TaskRecurrence.valid?("daily") # => true TaskRecurrence.valid?("hourly") # => true TaskRecurrence.valid?("every 10 seconds") # => true TaskRecurrence.valid?("every 5 minutes") # => true TaskRecurrence.valid?("every 2 hours") # => true TaskRecurrence.valid?("every 3 days") # => true TaskRecurrence.valid?("every monday") # => true TaskRecurrence.valid?("biweekly") # => false TaskRecurrence.valid?(nil) # => false ``` ### Complete a Recurring Task (via NisTask) ```elixir # Completes the task and auto-creates the next instance NisTask.complete_recurring("james", task_id, "Asia/Ho_Chi_Minh") # => {:ok, %{completed: %{...}, next: %{...}}} ``` ### Complete via NisTaskRest (with Pushover alarm propagation) ```elixir # If the completed task had a Pushover alarm, it's propagated to the next instance NisTaskRest.complete_recurring("james", task_id, "Asia/Ho_Chi_Minh") # => {:ok, %{completed: %{...}, next: %{...}}} ``` ## API Reference ### `next_due_at/3` Calculate the next due date for a recurring task. **Parameters:** - `current_due_at` (integer or nil) — current due_at in Unix seconds; if `nil`, uses current time - `recur` (string) — recurrence pattern string - `tz` (string, optional) — IANA timezone, defaults to `"Etc/UTC"` **Returns:** Unix timestamp (integer) for the next occurrence, or `nil` if the pattern is invalid. ```elixir # From a specific time next = TaskRecurrence.next_due_at(1771639200, "daily") # => 1771725600 # From nil (uses current time) next = TaskRecurrence.next_due_at(nil, "weekly", "Asia/Ho_Chi_Minh") # Invalid pattern next = TaskRecurrence.next_due_at(1771639200, "biweekly") # => nil ``` ### `valid?/1` Check whether a recurrence string is a supported pattern. **Parameters:** - `recur` (any) — the value to validate **Returns:** `true` if valid, `false` otherwise. ```elixir TaskRecurrence.valid?("daily") # => true TaskRecurrence.valid?("hourly") # => true TaskRecurrence.valid?("every 10 seconds") # => true TaskRecurrence.valid?("every 5 minutes") # => true TaskRecurrence.valid?("every 2 hours") # => true TaskRecurrence.valid?("every 2 weeks") # => true TaskRecurrence.valid?("every monday,friday") # => true TaskRecurrence.valid?("every other tuesday") # => false TaskRecurrence.valid?(42) # => false ``` ## Recurrence Patterns ### Simple Intervals | Pattern | Computation | Timezone needed? | |---------|------------|-----------------| | `every N seconds` | `+N` seconds | No | | `every N minutes` | `+N * 60` seconds | No | | `every N hours` | `+N * 3,600` seconds | No | | `hourly` | `+3,600` seconds | No | | `daily` | `+86,400` seconds | No | | `weekly` | `+604,800` seconds | No | | `every N days` | `+N * 86,400` seconds | No | | `every N weeks` | `+N * 604,800` seconds | No | ### Calendar-Aware | Pattern | Computation | Timezone needed? | |---------|------------|-----------------| | `monthly` | Same day next month, day clamped to month max | Yes | | `weekdays` | +1 day, but Fri→Mon (+3), Sat→Mon (+2) | Yes | | `every mon,wed,fri` | Next matching weekday after current | Yes | ### Monthly Day Clamping When the current day doesn't exist in the next month, it's clamped to the last day: ``` Jan 31 → Feb 28 (or 29 in leap year) Mar 31 → Apr 30 Aug 31 → Sep 30 ``` ### Named Day Selection For patterns like `every monday,wednesday,friday`, the engine finds the nearest upcoming matching weekday: ``` Current: Wednesday → next occurrence: Friday (2 days) Current: Friday → next occurrence: Monday (3 days) Current: Monday → next occurrence: Wednesday (2 days) ``` ## Completion Flow When a recurring task is completed via `NisTask.complete_recurring/3`: ``` 1. Original task marked "completed" (status + completed_at set) 2. TaskRecurrence.next_due_at/3 calculates next due date 3. New task created with: - Same: title, priority, tags, note, parent_id, recur - New: due_at (shifted), status = "todo" - Cleared: completed_at, after_at, expires_at 4. Returns {:ok, %{completed: old_task, next: new_task}} ``` When completed via `NisTaskRest.complete_recurring/4`, Pushover alarm propagation is automatic — if the completed task had an `alarm_id`, a new alarm is created for the next instance at its new `due_at`. > Recurring instances are **independent** — no parent-child chain. Each instance is a standalone task that happens to share the same recurrence pattern. If the task has no `recur` field, `complete_recurring/3` behaves identically to `complete/2` — just marks it done with no next instance. ## Related Documentation - [TaskCapture](task_capture.md) — natural language parser that extracts recurrence patterns - [NisTask](nis_task.md) — database layer, `complete_recurring/3` implementation - [NisTaskRest](nis_task_rest.md) — REST bridge with Pushover alarm propagation on recurring complete --- *Source: `lib/task_recurrence.ex` — Last updated: 2026-03-03*