docs(06-02): complete RRULE UNTIL/COUNT serialization plan
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
plan: "02"
|
||||
subsystem: api/broker
|
||||
tags: [tdd, rrule, recurrence, serialization, ical.js, zod]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- assembleRruleString helper in apps/api/src/broker/outboxWorker.ts
|
||||
- recurrenceUntil/recurrenceCount fields in outboxPayloadSchema + eventFieldsSchema
|
||||
affects:
|
||||
- apps/api/src/broker/outboxWorker.ts
|
||||
- apps/api/src/routes/events.ts
|
||||
- apps/api/tests/broker/vevent.test.ts
|
||||
- apps/api/tests/broker/outboxWorker.test.ts
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- ical.js ICAL.Recur.fromString + rruleProp.setValue for RRULE serialization
|
||||
- assembleRruleString count-wins-over-until mutual exclusion (RFC 5545 §3.3.10)
|
||||
- Series-edit Pitfall 3: strip UNTIL/COUNT via regex before re-applying new bound
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/api/src/broker/outboxWorker.ts
|
||||
- apps/api/src/routes/events.ts
|
||||
- apps/api/tests/broker/vevent.test.ts
|
||||
- apps/api/tests/broker/outboxWorker.test.ts
|
||||
decisions:
|
||||
- "assembleRruleString: COUNT takes precedence over UNTIL (mutual exclusion, RFC 5545 §3.3.10)"
|
||||
- "Timed UNTIL serializes as YYYYMMDDTHHMMSSZ (end-of-UTC-day T235959Z) per RESEARCH Pitfall 2"
|
||||
- "hasExplicitRecurrence check gates assembleRruleString; recurrence:'none' explicitly yields undefined (no RRULE)"
|
||||
- "Series-edit bound-only change: regex strips existing UNTIL/COUNT from preserved RRULE before re-applying new bound"
|
||||
metrics:
|
||||
duration_minutes: 8
|
||||
completed_date: "2026-06-10"
|
||||
tasks_completed: 2
|
||||
files_modified: 4
|
||||
---
|
||||
|
||||
# Phase 06 Plan 02: RRULE UNTIL/COUNT Serialization + FREQ Persistence Summary
|
||||
|
||||
**One-liner:** RRULE UNTIL/COUNT serialization with value-type-matching (DATE vs DATETIME UTC) via `assembleRruleString`, wired into both create + update outbox branches, with a FREQ=DAILY regression lock.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| # | Name | Commit | Type |
|
||||
|---|------|--------|------|
|
||||
| 1 | RED — failing tests for UNTIL/COUNT serialization + FREQ-persistence regression | a59455a | test |
|
||||
| 2 | GREEN — assembleRruleString + Zod schema acceptance, wired into the write path | d2abb91 | feat |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: RED
|
||||
Added failing tests to two files:
|
||||
|
||||
**`vevent.test.ts`** — three new serialization assertions confirming ical.js 2.2.1 handles UNTIL/COUNT correctly via the existing `ICAL.Recur.fromString` path:
|
||||
- `FREQ=WEEKLY;COUNT=5` → `RRULE:FREQ=WEEKLY;COUNT=5`
|
||||
- `FREQ=DAILY;UNTIL=20260630` (all-day) → contains `RRULE:FREQ=DAILY;UNTIL=20260630`, does NOT contain `T235959Z`
|
||||
- `FREQ=WEEKLY;UNTIL=20260630T235959Z` (timed) → `RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z`
|
||||
|
||||
**`outboxWorker.test.ts`** — two new describe blocks:
|
||||
- `assembleRruleString (D-06)`: 6 cases covering COUNT wins, UNTIL DATE/DATETIME, COUNT-wins-over-UNTIL mutual exclusion, base preset unchanged
|
||||
- `FREQ persistence (D-07 regression)`: 1 case asserting daily-recurrence payload emits `RRULE:FREQ=DAILY`
|
||||
|
||||
RED confirmed: `assembleRruleString is not a function` (6 failing tests).
|
||||
|
||||
### Task 2: GREEN
|
||||
|
||||
**`apps/api/src/broker/outboxWorker.ts`:**
|
||||
- Added `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to `outboxPayloadSchema` (T-06-02 mitigations)
|
||||
- Implemented and exported `assembleRruleString(basePreset, until?, count?, allDay?)` with JSDoc (D-06)
|
||||
- Wired `assembleRruleString` into both create and update dispatch branches
|
||||
- Fixed precedence: `hasExplicitRecurrence` checked first (covers `recurrence:'none'` → explicitly yields `undefined`); `preservedRrule` only used when no explicit recurrence
|
||||
- Series-edit Pitfall 3: when a bound-only change applies to a preserved RRULE, strips `UNTIL/COUNT` via `/;(UNTIL|COUNT)=[^;]*/g` before re-applying
|
||||
|
||||
**`apps/api/src/routes/events.ts`:**
|
||||
- Added `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to `eventFieldsSchema`
|
||||
|
||||
All 39 tests pass. The previously passing CR-01 (`recurrence:'none' wins over _preservedRrule`) was initially broken by the change and auto-fixed (Rule 1 bug: logic precedence error).
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
| Gate | Status |
|
||||
|------|--------|
|
||||
| RED commit (`test(06-02):`) | a59455a — exists, confirmed failing |
|
||||
| GREEN commit (`feat(06-02):`) | d2abb91 — follows RED commit |
|
||||
| Commit order | test(06-02) precedes feat(06-02) — verified via `git log` |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Fixed hasExplicitRecurrence precedence for recurrence:'none'**
|
||||
- **Found during:** Task 2 (GREEN)
|
||||
- **Issue:** Initial implementation used `if (hasExplicitRecurrence && rruleFromPayload)` — when `recurrence:'none'`, `rruleFromPayload` is `undefined`, so the condition was `false`, incorrectly falling through to `else if (preservedRrule)` and emitting an RRULE even though the user explicitly selected 'none'. Broke existing `CR-01: explicit recurrence:'none' wins` test.
|
||||
- **Fix:** Changed to `if (hasExplicitRecurrence)` with an inner ternary: if `rruleFromPayload` is truthy, assemble with bound; otherwise `undefined`. Applied identically to both create and update branches.
|
||||
- **Files modified:** `apps/api/src/broker/outboxWorker.ts`
|
||||
- **Commit:** d2abb91 (folded into GREEN commit)
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
```
|
||||
cd apps/api && pnpm vitest run tests/broker/vevent.test.ts tests/broker/outboxWorker.test.ts
|
||||
Test Files 2 passed (2)
|
||||
Tests 39 passed (39)
|
||||
```
|
||||
|
||||
```
|
||||
grep -n "recurrenceUntil" apps/api/src/routes/events.ts apps/api/src/broker/outboxWorker.ts
|
||||
events.ts:111: recurrenceUntil: z.string().max(10).optional()
|
||||
outboxWorker.ts:84: recurrenceUntil: z.string().max(10).optional()
|
||||
```
|
||||
|
||||
Git log confirms `test(06-02)` precedes `feat(06-02)`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. All test assertions target exact ICS/RRULE strings verified against ical.js 2.2.1 in RESEARCH. No placeholder data.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new threat surface beyond what was planned in T-06-02 / T-06-02b. Both mitigations implemented:
|
||||
- `z.string().max(10)` on `recurrenceUntil` + `z.number().int().min(1)` on `recurrenceCount` at both route and outbox schema boundaries.
|
||||
- `assembleRruleString` uses `.replace(/-/g,'')` (digits only) + fixed templates — no raw passthrough to ICS.
|
||||
- Assembled string passes through `ICAL.Recur.fromString` (parse-rejects malformed RRULE).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| SUMMARY.md created | FOUND |
|
||||
| RED commit a59455a | FOUND |
|
||||
| GREEN commit d2abb91 | FOUND |
|
||||
| 39 tests passing | CONFIRMED |
|
||||
| recurrenceUntil in both schemas | CONFIRMED |
|
||||
Reference in New Issue
Block a user