Skip to content

Add recurring tasks - #16

Merged
janitorr merged 11 commits into
mainfrom
add-recurring-tasks
Aug 17, 2026
Merged

Add recurring tasks#16
janitorr merged 11 commits into
mainfrom
add-recurring-tasks

Conversation

@janitorr

Copy link
Copy Markdown
Owner

Summary

Adds recurring task support: templates that repeatedly generate one-shot task instances (weekly meetings, monthly bills, etc.). Creating a template immediately creates the first instance due on the start date; completing an instance schedules the next one. Templates never appear in reports — only their generated instances do.

Implements OpenSpec change add-recurring-tasks (35/35 tasks, openspec validate clean).

What changes

  • Domain model (Nagger.Core): RecurringTaskTemplate, RecurrenceRule/RecurrenceUnit, RecurringTaskStatus, RecurringTaskNotFoundException, plus IRecurringTaskTemplateStore port and a GetByRecurringTaskIdAsync on ITaskStore.
  • Recurrence logic: RecurrenceCalculator.CalculateNextDue with month-end clamping (e.g. Jan 31 + 1 month → Feb 28).
  • Handlers: create (template + first instance), complete (marks done + schedules next), pause/resume (template + current instance), cancel (template + all instances), list (ascending id).
  • One-shot integration: completing a recurring-generated instance via POST /tasks/{id}/complete now spawns the next instance.
  • Persistence: new recurring_task_templates table and nullable recurring_task_id on one_shot_tasks via EF migration 20260806160229_AddRecurringTasks.
  • HTTP API:
    Endpoint Purpose
    POST /tasks/recurring Create template + first instance
    GET /tasks/recurring List templates (ascending id)
    POST /tasks/recurring/{id}/complete Complete an instance (instance id)
    POST /tasks/recurring/{id}/pause · /resume · /cancel Template lifecycle (template id)
  • MCP tools: create_recurring_task, complete_recurring_task, pause_recurring_task, resume_recurring_task, cancel_recurring_task, list_recurring_tasks.
  • Docs: USAGE.md documents the new endpoints and tools.

Notable fixes

  • Corrected DateOnlyExtensions.ToDateTimeOffset so instance due dates use the configured timezone rather than the system-local timezone.
  • ApiExceptionHandler now maps RecurringTaskNotFoundException to 404 (was falling through to 500).
  • Removed an unrelated net8.0 target downgrade of Nagger.Core; kept AllowMissingPrunePackageData in the Host project, which is required to build in this environment.

Verification

  • dotnet build Nagger.slnx — clean
  • dotnet test Nagger.slnx120/120 pass (63 Core unit + 57 Host integration, incl. new recurring API/MCP/Core tests)
  • dotnet ef migrations has-pending-model-changes — none
  • openspec validate add-recurring-tasks — valid

Jani added 6 commits August 6, 2026 17:20
- proposal.md: Define why, what changes, and capabilities
- specs/: Behavior contracts for recurring task creation, lifecycle, listing
- design.md: Technical approach with separate vertical slices
- tasks.md: Implementation checklist with 28 trackable tasks
Comment thread src/Nagger.Host/Mcp/McpTaskTools.cs Outdated

[McpServerTool(Name = "complete_recurring_task", UseStructuredContent = true, OutputSchemaType = typeof(McpTaskResponse))]
[Description("Use when the user says an active recurring-task instance is finished. Marks it done and schedules the next instance from the template's recurrence.")]
public Task<CallToolResult> CompleteRecurringTask([Description("Identifier of the recurring-task instance returned by list_recurring_tasks or the morning report.")] long id, CancellationToken cancellationToken) =>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

complete_recurring_task takes an instance (task) id, not a template id. list_recurring_tasks returns template ids, so this description misdirects the model: a template id passed here 404s (TaskNotFoundException) or, worse, completes an unrelated task whose instance id happens to equal the template id. Point the model at list_one_shot_tasks or the morning report for the instance id instead.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The complete tool now points the model at list_one_shot_tasks or the morning report for the instance id, not list_recurring_tasks (template ids).

Comment thread src/Nagger.Host/Mcp/McpTaskTools.cs Outdated
Run(async () => McpRecurringTemplateResponse.From(await mediator.Send(new CancelRecurringTaskCommand(id), cancellationToken)));

[McpServerTool(Name = "list_recurring_tasks", ReadOnly = true, UseStructuredContent = true, OutputSchemaType = typeof(McpRecurringTemplateResponse[]))]
[Description("Use to discover recurring task templates. Each returned id is the identifier required by recurring lifecycle tools.")]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_recurring_tasks returns template ids, which are only valid for pause/resume/cancel (template lifecycle). complete_recurring_task needs an instance (task) id. Consider scoping this description to the template-lifecycle tools so the model does not hand a template id to complete_recurring_task.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The list_recurring_tasks description now scopes the returned ids to pause/resume/cancel (template lifecycle) and points to list_one_shot_tasks or the morning report for instance ids used by complete_recurring_task.

?? throw new RecurringTaskNotFoundException(task.RecurringTaskId.Value);

var nextDueDate = RecurrenceCalculator.CalculateNextDue(
DateOnly.FromDateTime(updated.CompletedAt!.Value.Date),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next-due date is derived from the UTC completion date (updated.CompletedAt!.Value.Date), but CompletedAt is clock.UtcNow (offset 0). For the default Europe/Helsinki timezone, completing a task after ~22:00 local yields a UTC date one day behind the user local date, shifting the next due date a day early. This is inconsistent with CreateRecurringTaskHandler.Today(), which converts to clock.TimeZone before taking the date. Consider converting the completion instant to clock.TimeZone first.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in CompleteRecurringTaskHandler. Next-due is now derived from the completion instant converted to clock.TimeZone (DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(completedAt, clock.TimeZone).Date)), matching CreateRecurringTaskHandler.Today(). Added a regression test (Helsinki, 01:30 local completion) asserting the next due date uses the local date.


// Calculate next due date
var nextDueDate = RecurrenceCalculator.CalculateNextDue(
DateOnly.FromDateTime(updated.CompletedAt!.Value.Date),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same timezone issue as CompleteRecurringTaskHandler: CompletedAt!.Value.Date is the UTC date. In Europe/Helsinki, late-evening completions produce a next-due date one day early. Consider converting CompletedAt to clock.TimeZone before taking the date.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in CompleteOneShotTaskHandler with the same timezone conversion, plus a matching Helsinki regression test. Both handlers now agree with the configured-timezone date used elsewhere.

var daysInMonth = DateTime.DaysInMonth(year, month);

// Handle edge case where day doesn't exist in target month
if (day > daysInMonth)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because next-due is always recomputed from the completion date (never an anchored original date), month-end clamping causes permanent drift: a monthly 31st task completed Jan 31 -> Feb 28 -> Mar 28 -> Apr 28. This is consistent with the completion-date + interval design, so flagging as a behavior note in case the drift is unintended.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this drift is intended per the spec/design. Both specs (one-shot-task-lifecycle and recurring-task-lifecycle) define the next due as completion date + recurrence interval — the interval is recomputed from each completion, not anchored to an original date. The design also explicitly scopes out "complex recurrence rules" as a non-goal, so we're keeping completion-date + interval as-is. Not changing code here; flagging as a known behavior note.

@janitorr

Copy link
Copy Markdown
Owner Author

Thanks — verified the fixes:

  • MCP tool guidance: complete_recurring_task now references instance ids from list_one_shot_tasks/morning report, and list_recurring_tasks scopes its ids to pause/resume/cancel. Correct.
  • Timezone handling: both CompleteRecurringTaskHandler and CompleteOneShotTaskHandler now derive the next-due date from the completion instant converted to clock.TimeZone, with Helsinki late-evening tests covering the shift.

The month-end clamping note in RecurrenceCalculator is unchanged and remains consistent with the documented "completion date + interval" design. LGTM.

@janitorr
janitorr merged commit 5751b04 into main Aug 17, 2026
2 checks passed
@janitorr
janitorr deleted the add-recurring-tasks branch August 17, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant