#66 — Tech Debt: Monthly Recurrence Permanently Drifts Off Its Original Day-of-Month #66
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#66
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tech Debt: Monthly Recurrence Permanently Drifts Off Its Original Day-of-Month
Reported by:
/code-review, spotted during#20's code review, 2026-07-22Symptom
RecurrenceCalculator.NextOccurrence(CqsTodo/Features/Todos/RecurrenceCalculator.cs) computesMonthlyrecurrence ascurrent.AddMonths(1)applied to the previous occurrence's own duedate, not a separately-stored "anchor day of month" — this is intentional per
#20's design (noextra field; the day-of-month is whatever
DueDatealready encodes).DateOnly.AddMonthsclamps to the last valid day of a shorter target month. Because each newoccurrence's due date becomes the anchor for computing the next one, a clamp is never undone:
AddMonths(1)is applied to Feb 28, the already-clamped value, not the original 31.for every month going forward, silently changing the user's chosen schedule with no notice.
Daily and Weekly recurrence are unaffected (no clamping occurs for
+1 day/+7 days).Expected behaviour
Monthly recurrence should either:
requires storing that anchor separately from the current
DueDate, since the clamped valueitself no longer carries the information; or
whatever day the previous occurrence happened to fall on, so a user picking the 31st
understands their schedule will settle into a shorter day after the first short month.
Acceptance criteria
(or the last day of the month, for months with <31 days) every month — not permanently reduced
to the 28th/29th after the first February
each due date lands on the expected day)
Notes for whoever picks this up
This is the direct trade-off
#20's design doc accepted ("no separate day-of-week/day-of-monthfield") — fixing it properly likely means reopening that decision (adding an anchor field) rather
than a small patch to
RecurrenceCalculator. Low urgency: only affects Monthly recurrencespecifically anchored on the 29th/30th/31st of a month, and only after the first month-length
mismatch.
Agents involved
accepted trade-off
Blockers
None.
design (
66_recurring_todo_monthly_day_drift_design.md)#66— Design NoteDecision: reopen
#20's design, add the anchor fieldPer the story's own framing, this required revisiting
#20's accepted trade-off ("no separateday-of-week/day-of-month field"). Added
TodoEntity.RecurrenceAnchorDay(nullableint, plain —not a Vogen VO, matching
SortOrder's precedent for an internal, non-user-facing integer) ratherthan reworking the whole recurrence model — Weekly still needs no anchor at all (no clamping
occurs), only Monthly does.
Mechanics
RecurrenceCalculator.NextOccurrencegains a requiredanchorDayparameter. For Monthly:current.AddMonths(1)is still used, but only for its year/month — its own day is discarded(that's the value the whole bug was about trusting) and replaced with
Math.Min(anchorDay, DateTime.DaysInMonth(targetYear, targetMonth)), computed fresh against thefixed anchor every call. Jan 31 (anchor 31) → Feb 28 → Mar 31 → Apr 30, never drifting to a
permanently-reduced day the way
current.AddMonths(1)chained on itself would.TodoEntity.RecurrenceAnchorDayis set in exactly one of two ways, both inSetTodoRecurrenceCommandHandler: derived from the todo's own current due date's day whenrecurrence is set for the first time (the natural, only-sensible anchor at that moment), or
passed verbatim via a new
AnchorDayOverrideconstructor parameter whenCheckTodoCommandHandlercarries an existing recurrence forward onto a freshly-spawned next occurrence — carrying
forward must reuse the original anchor unchanged, never recompute one from the new occurrence's
own due date (which may already be a clamped value). Same "public command, trusted
caller-supplied override" shape as the existing
AssignTodoCommand.NotifyAssigneeprecedent.RollForwardMissedRecurringTodosCommandHandler's multi-step catch-up loop (a todo missedseveral months while the app was down) also threads the same fixed anchor through every
iteration, for the same reason.
RecurrenceRule: null) clears the anchor too, keeping the two fields inlockstep — a todo with no recurrence rule never has a stale anchor value sitting around.
Existing recurring todos (pre-migration data)
Rows that already had
RecurrenceRuleset before this migration getRecurrenceAnchorDay = null(the new column's default) — their day-of-month may already be drifted from whatever the user
originally intended, and this migration doesn't attempt to reconstruct history to guess the
original anchor. Every read path that consumes the anchor falls back to
?? dueDate.Daywhennull, so behavior for already-drifted todos is unchanged (no regression) until the next
transition (completion or missed-rollover), at which point a real anchor gets established from
whatever due date is current at that moment and drift stops going forward from there. Not
retroactively fixed, but self-healing on the next natural transition rather than requiring a
backfill migration.
Test coverage
RecurrenceCalculatorTests.cs: 2 new tests directly reproduce the ticket's own bug scenario(Jan 31 → Feb 28 → Mar 31 → Apr 30, and a 30th-anchored case that clamps every February but
recovers every other month) — both pass locally (pure C#, no DB needed).
SetTodoRecurrenceCommandHandlerTests.cs: 3 new tests cover first-time anchor derivation,AnchorDayOverridetaking precedence, and anchor clearing alongside rule clearing.RollForwardMissedRecurringTodosCommandHandlerTests.csgained a multi-month catch-up test(a todo anchored on the 31st, missed for over a year, rolled forward in one sweep) — code review
correctly flagged the initial omission of this case as a real gap, since it's the literal
multi-month scenario the ticket describes. The test derives its expected end date by calling the
same
RecurrenceCalculator.NextOccurrencethe handler itself uses, in a loop, rather thanhand-computing a long calendar chain — an integration-level check that the handler correctly
threads the persisted anchor through every catch-up iteration, layered on top of
RecurrenceCalculatorTests.cs's already-thorough direct coverage of the calculation itself.