---
phase: '06-ux-polish'
plan: '03'
subsystem: 'api/broker'
tags: ['tdd', 'expand', 'hasRrule', 'ical', 'recurrence', 'd-08', 'd-06']
dependency_graph:
  requires:
    - 'apps/api/src/broker/expand.ts (CalendarOccurrence interface)'
    - 'apps/api/tests/fixtures/*.ics (existing fixtures)'
  provides:
    - 'CalendarOccurrence.hasRrule: boolean (server source-of-truth)'
    - 'weekly-count3.ics test fixture (bounded RRULE, COUNT=3)'
    - 'expand.test.ts hasRrule + bounded-RRULE assertions'
  affects:
    - 'apps/api/src/broker/expand.ts (CalendarOccurrence consumers — routes/events.ts)'
    - 'apps/pwa/src/api/client.ts (mirror field added in Plan 05)'
tech_stack:
  added: []
  patterns:
    - 'TDD RED→GREEN: test-only commit followed by implementation commit'
    - 'Capture event.isRecurring() once before branch, pass to both push sites'
    - 'epochMilliseconds (not epochSeconds) for Temporal duration arithmetic with temporal-polyfill'
key_files:
  created:
    - 'apps/api/tests/fixtures/weekly-count3.ics'
  modified:
    - 'apps/api/src/broker/expand.ts'
    - 'apps/api/tests/broker/expand.test.ts'
decisions:
  - 'D-08: hasRrule derived from event.isRecurring() — no DB query change needed (already available on the parsed ICAL.Event)'
  - 'Captured isRecurring once before the non-recurring/recurring branch (single capture pattern from PATTERNS.md)'
  - 'epochMilliseconds used for Temporal duration math — temporal-polyfill returns number not BigInt for this property'
  - 'weekly-count3.ics uses UTC DTSTART/DTEND (no VTIMEZONE needed) for simplicity in the bounded test fixture'
metrics:
  duration: '11m'
  completed: '2026-06-10'
  tasks_completed: 2
  files_modified: 3
---

# Phase 06 Plan 03: hasRrule Server-Side Exposure Summary

Added `hasRrule: boolean` to the `CalendarOccurrence` interface in `expand.ts` and populated it via `event.isRecurring()` — the server-side signal that gates the whole-series-edit confirmation prompt (D-08).

## Tasks Completed

| #   | Task                                                 | Type     | Commit  | Outcome                                           |
| --- | ---------------------------------------------------- | -------- | ------- | ------------------------------------------------- |
| 1   | RED — failing tests for hasRrule + bounded expansion | TDD test | 593302e | 3 hasRrule failures + duration test confirmed red |
| 2   | GREEN — add hasRrule to interface and populate it    | TDD impl | 44d336c | 10/10 expand tests pass                           |

## What Was Built

### `apps/api/src/broker/expand.ts`

Added `hasRrule: boolean` field to `CalendarOccurrence` interface with JSDoc. Captured `const isRecurring = event.isRecurring()` once before the non-recurring/recurring branch. Both `occurrences.push({...})` sites now include `hasRrule: isRecurring` — false in the non-recurring branch, true in the recurring branch.

### `apps/api/tests/broker/expand.test.ts`

Added two new `describe` blocks:

**`hasRrule field — D-08`** (2 tests):

- `weekly-dst.ics` (recurring): all occurrences have `hasRrule === true`
- `single-duration.ics` (non-recurring): the single occurrence has `hasRrule === false`

**`Bounded RRULE (COUNT=3) — D-06 invariant`** (3 tests):

- `COUNT=3` within a 6-month window returns exactly 3 occurrences
- Each bounded occurrence duration = 1 hour from DTSTART→DTEND (not recurrence span)
- Bounded occurrences have `hasRrule === true`

### `apps/api/tests/fixtures/weekly-count3.ics`

New fixture: `FREQ=WEEKLY;COUNT=3`, `DTSTART:20260601T090000Z`, `DTEND:20260601T100000Z` (1-hour UTC events). Used for the bounded expansion invariant test.

## TDD Gate Compliance

- RED commit (`test(06-03): ...`) at `593302e` — tests written first, confirmed failing due to absent `hasRrule` field
- GREEN commit (`feat(06-03): ...`) at `44d336c` — implementation added, all 10 tests pass

## Deviations from Plan

**1. [Rule 1 - Bug] Duration test using `epochMilliseconds` instead of `epochSeconds`**

- **Found during:** Task 1 test writing
- **Issue:** `Temporal.ZonedDateTime.epochSeconds` returns `NaN` in the `temporal-polyfill` package used in the test suite; `epochMilliseconds` returns a regular `number`
- **Fix:** Duration assertion uses `endZdt.epochMilliseconds - startZdt.epochMilliseconds` and compares to `3_600_000` (1 hour in ms)
- **Files modified:** `apps/api/tests/broker/expand.test.ts`
- **Commit:** 593302e (incorporated into RED commit before final form)

No other deviations. Plan executed as written.

## Verification

```
npx vitest run tests/broker/expand.test.ts
Test Files  1 passed (1)
     Tests  10 passed (10)
```

```
grep -n "hasRrule" apps/api/src/broker/expand.ts
68:  hasRrule: boolean
224:  // Capture once — used in both branches to populate hasRrule.
261:        hasRrule: isRecurring, // always false in the non-recurring branch
308:      hasRrule: isRecurring, // always true in the recurring branch
```

`client.ts` untouched — mirror field is Plan 05's responsibility.

## Known Stubs

None. `hasRrule` is fully populated from `event.isRecurring()` — no placeholder values.

## Threat Flags

No new threat surface. `hasRrule` is a boolean derived from data already returned to the authenticated caller. T-06-03 accepted in plan threat model.

## Self-Check: PASSED

- `apps/api/src/broker/expand.ts` exists and contains `hasRrule`
- `apps/api/tests/fixtures/weekly-count3.ics` exists
- `apps/api/tests/broker/expand.test.ts` contains `hasRrule` assertions
- Commits 593302e and 44d336c exist in git log
