Commit Graph
114 Commits
Author SHA1 Message Date
Lucas Berger b1dc9b8048 test(04-04): add failing tests for item CRUD endpoints + rank helpers
- Add rank.test.ts: unit tests for rankForAppend/rankBetween (RED - no impl yet)
- Extend lists.test.ts with item route tests: POST /:id/items, GET /:id/items,
  PATCH /list-items/:id (per-field LWW D-08), DELETE /list-items/:id (D-09)
- Import listItems from schema; add seedItem helper
- Tests cover: fractional rank assignment (D-13), exact-one-field refine (T-04-07),
  uncheck rank recompute, access gating T-04-05, delete-wins no resurrection D-09
2026-06-09 12:52:34 -04:00
Lucas Berger 9546b747d2 feat(04-03): implement listsRouter POST/GET/PATCH/DELETE /api/lists (LIST-01)
- GET /: scoped access (owner + list_shares); activeCount/doneCount per list
- POST /: auto-populates list_shares for all other members when isShared=true (D-01/D-02)
- PATCH /🆔 rename + isShared toggle; reconciles list_shares on visibility change
- DELETE /🆔 owner-only; cascade handles items/shares via FK onDelete cascade
- resolveUserId helper copied verbatim from events.ts per project convention
- zod createListSchema (name 1..255, isShared default true) + patchListSchema
- T-04-02 / T-04-05 / T-04-07 / T-04-08 mitigations applied
- listsRouter mounted at /api/lists in index.ts (after sseRouter)
- Plan 06 SSE seam comments left at every mutation handler
- [Rule 1 - Fix] zValidator returns 400 (not 422); tests corrected to match convention
- All 23 tests green; full API suite 140 passed no regressions
2026-06-09 12:38:05 -04:00
Lucas Berger 792efeb3df feat(04-02): implement listEmitter + listAccess; all 9 tests GREEN
listEmitter.ts:
- Module-level EventEmitter singleton; setMaxListeners(200) (T-04-04)
- publishListEvent(listId, event): emits on list:${listId} channel
- subscribeListEvents(listId, handler): registers listener, returns unsub closure
- ListEvent type union: item:added/updated/deleted, list:updated/deleted
- D-04 isolation guaranteed by per-list channel keying

listAccess.ts:
- getAccessibleListIds(userId): two SELECT queries (owned + shared), Set dedupe
- Satisfies T-04-02/T-04-03: over-returning proven impossible by Test 7

listAccess.test.ts fix:
- Use randomUUID() suffix in seedUser to avoid oidc_sub unique-key collisions
  across test re-runs (users table not truncated by global afterEach)

vitest.config.ts:
- pool: 'forks' + singleFork: true to prevent FK violations from concurrent
  DB workers racing against the shared-state global afterEach cleanup
- sequence.concurrent: false as belt-and-suspenders

ioredis NOT introduced (D-18 abstraction boundary satisfied)
2026-06-09 12:20:38 -04:00
Lucas Berger 60745b3281 refactor(04-01): move API list test stubs into tests/ mirror dir to match convention
The two Wave-0 RED stubs (lists.test.ts, listEmitter.test.ts) were co-located in
src/ but all existing API tests live in apps/api/tests/. Move them to tests/routes/
and tests/lib/, add explicit vitest imports to match the tests/ convention, and
update path references in downstream plans 04-02..04-06. PWA tests keep co-location
(that IS the PWA convention).
2026-06-09 12:10:31 -04:00
Lucas Berger 2f25b15949 feat(04-01): add list tables to schema and apply via generate+migrate [BLOCKING]
- Append lists, list_shares, list_items tables to Drizzle schema (schema.ts)
- lists: owner_id FK, is_shared bool default true (D-01), idx_lists_owner_id
- list_shares: list_id + user_id FKs, uniq_list_share, idx_list_shares_user_id (D-02)
- list_items: rank varchar for fractional-indexing (D-13), checked bool, composite indexes
- Generate 0000_easy_slipstream.sql (full schema baseline) + 0001_lists_schema.sql (new tables)
- Mark 0000 as applied in __drizzle_migrations (prior tables existed from manual DDL)
- Apply 0001_lists_schema.sql via db:migrate — lists/list_shares/list_items now in MariaDB
- Add vitest/globals + node to tsconfig types for test file compatibility
- NEVER used db:push (hard project constraint — drizzle-mariadb-push-unsafe)
- typecheck passes
2026-06-09 11:55:46 -04:00
Lucas Berger 39d4ec84c0 feat(04-01): install new deps + scaffold API test harness with Wave-0 RED stubs
- Add react-router@7, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing to PWA
- Add fractional-indexing to API (rank generation server-side)
- ioredis NOT added (in-memory EventEmitter per RESEARCH Plan 02 justification)
- Create apps/api/test/setup.ts with afterEach DB cleanup for list tables
- Wire test.setupFiles in apps/api/vitest.config.ts
- Add 4 Wave-0 RED stub test files (LIST-01/02/03/04, D-04, D-11, D-07)
- All stubs run as todo, not import-error
2026-06-09 11:49:10 -04:00
Lucas Berger b8c186491b fix(03): IN-03 re-validate outbox payload before VEVENT build, hard-fail invalid rows 2026-06-09 11:05:50 -04:00
Lucas Berger f95760e6c6 fix(03): IN-01 document RRULE_PRESETS lossy round-trip v1 limitation 2026-06-09 11:05:50 -04:00
Lucas Berger fd13852eb9 fix(03): WR-04 rank failed/dead outbox row above done in sync-status 2026-06-09 11:04:00 -04:00
Lucas Berger 5b720ffdb8 fix(03): WR-03 log unconditional PUT/DELETE when If-Match etag is missing 2026-06-09 11:04:00 -04:00
Lucas Berger 5168920eb1 fix(03): CR-01 preserve RRULE on edit-as-move (forward source rule to create row) 2026-06-09 10:59:19 -04:00
Lucas Berger 6d2fd79209 fix(03): IN-04 type resolveUserId param as Hono Context instead of any 2026-06-09 10:44:15 -04:00
Lucas Berger e29d6c1714 fix(03): IN-02 treat unmapped 4xx as hard fail (no full-backoff retry) 2026-06-09 10:43:14 -04:00
Lucas Berger 95f9d8c097 fix(03): IN-01 cache decrypted client per userId within a drain cycle 2026-06-09 10:42:41 -04:00
Lucas Berger 1c71f8c980 fix(03): WR-06 surface move-failed distinctly with re-save guidance 2026-06-09 10:41:55 -04:00
Lucas Berger 7bc129f0f3 fix(03): WR-04 start background workers only when run as entrypoint 2026-06-09 10:40:54 -04:00
Lucas Berger 22d1bc27d6 fix(03): WR-05 use resolved realpath for direct-run guard 2026-06-09 10:40:26 -04:00
Lucas Berger 5499f83782 fix(03): WR-02 make default-calendar selection deterministic (orderBy id, limit 1) 2026-06-09 10:39:28 -04:00
Lucas Berger 02aa407764 fix(03): WR-01 preserve existing RRULE on edit instead of resetting to none 2026-06-09 10:39:06 -04:00
Lucas Berger a596f520b4 fix(03): CR-02 scope freshest-etag re-read to the writing member's calendar 2026-06-09 10:36:18 -04:00
Lucas Berger 54addb1515 fix(03): CR-01 scope event edit/delete lookup to acting member's writable set 2026-06-09 10:35:51 -04:00
Lucas Berger d4d5327fc4 fix(calendar): all-day off-by-one (exclusive DTEND) + member color too close to shared rose
All-day: a single-day all-day event displayed across two days. iCal all-day
DTEND is EXCLUSIVE (1-day event = DTSTART:24/DTEND:25) and the server occurrence
carries that exclusive end, but Schedule-X treats all-day end as INCLUSIVE.
hydrateEvents now subtracts one day (clamped to >= start) so a 1-day event shows
on one day and an N-day event spans N days. Write path was already correct
(verified against stored VEVENTs). +regression test.

Color: a member's coral (#E8734A) was mistaken for the shared-family rose
(#F25C7A). Reorder COLOR_PALETTE so warm near-rose hues (amber, coral) are
assigned LAST; early members get cool, clearly-distinct colors (blue/green/teal).
2026-06-07 18:37:56 -04:00
Lucas Berger f700182674 fix(auth): assign first UNUSED palette color (AUTH-03 distinct colors)
Gate 2 A3 fail: a second member (amelia) got the same color as the first (luc),
both #E8734A. Color was assigned by COUNT(*) % palette; a deleted spike user
shifted the count so two live members landed on the same slot. Replace with
'first palette color not already in use by another user' (fall back to count
round-robin only once the palette is exhausted) — guarantees distinct, stable
colors for up to palette length members. +1 regression test (deletion frees a
slot → next member fills it, no collision).
2026-06-07 18:14:20 -04:00
Lucas Berger 2c8f1a28af fix(auth): self-healing displayName; drop synthetic Member<sub> from storage
The legend showed 'Member 972be1a3' because Authelia does not emit
name/preferred_username/email in the ID TOKEN (only at the userinfo endpoint),
and @hono/oidc-auth reads ID-token claims only. The real fix is an Authelia
claims_policy adding those claims to id_token for the familysync client.

App-side robustness so it self-heals once Authelia is fixed (no DB surgery):
- deriveDisplayName now returns null (not a synthetic 'Member <sub>') when no
  real claim is present, so we never persist an ugly sub string; the UI degrades
  to a generic 'Member'.
- upsertUser now tracks the IdP name authoritatively: a non-null displayName that
  differs from the stored value updates the row (blank/stale 'Member …'/email →
  real name on next login). A null value never overwrites a good stored name.
2026-06-07 16:17:17 -04:00
Lucas Berger 98753d8e34 fix(broker): reconcile deletes into cache + resync before marking outbox done
Two write-path cache bugs surfaced during Gate 2 live testing:

P1 (delete didn't work / ghost event): syncCalendar only UPSERTED events
present on Fastmail and never removed cache rows for events that disappeared.
A successful CalDAV delete left the row in calendar_events forever, so
GET /api/events kept returning it and the UI showed a ghost that 'wouldn't
delete' (even after refresh). Add a prune step: delete calendar_events rows for
this calendar whose uid is absent from the server response (scoped to cal.id so
it never touches another calendar or the other member's rows — BUG B). Empty
server result prunes the whole calendar's cache.

P2 (edit needed a manual refresh): the outbox worker marked a row 'done' BEFORE
triggerTargetedResync refreshed the cache. The PWA's SyncStateToast invalidates
['events'] the instant sync-status flips to 'done', so it refetched stale cache.
Re-sync first, then mark done — 'done' now guarantees the cache reflects the write.

Tests: +2 prune regressions (present-subset prune, empty-server prune-all).
2026-06-07 16:12:43 -04:00
Lucas Berger a99ef1daae refactor(260607-l6l): extract shared deriveDisplayName helper (BUG 2 DRY)
The displayName claim-preference logic (name → preferred_username → email →
sub fallback) was duplicated verbatim in me.ts and events.ts resolveUserId.
Extract it to auth/user.ts as deriveDisplayName and use it in both call sites,
so the rule has one definition. Update the events.test.ts user.js mock to keep
the real helper (spread importActual) while stubbing only upsertUser.
2026-06-07 15:37:18 -04:00
Lucas Berger 00a0454514 fix(260607-l6l): scope GET /api/events to current user + shared calendars
BUG 3: GET / had no ownership predicate — it returned all users' events.
Second household member would see other member's private events.

- Resolve currentUserId at top of GET handler (same resolveUserId helper
  as write endpoints); return 401 if unauthenticated.
- Add ownership predicate to WHERE: AND (calendars.userId = currentUserId
  OR calendars.isShared = true). Combined with and() around the existing
  date-window or() block. Mirrors the /writable-calendars idiom (D-03).
2026-06-07 15:28:54 -04:00
Lucas Berger 23c8bb3402 fix(260607-l6l): derive displayName from OIDC claims in me.ts + resolveUserId
BUG 2: Both me.ts and events.ts resolveUserId were passing email (often
absent) as displayName to upsertUser, resulting in blank legend names.
Also, upsertUser returned existing rows unchanged even when displayName
was null and a better value was now available.

- me.ts: derive displayName via name → preferred_username → email →
  "Member <sub-prefix>" fallback, checked defensively. Updated JSDoc.
- events.ts resolveUserId: same derivation so write-path upserts don't
  re-blank a correctly-set displayName.
- user.ts: when existing row has null displayName and caller supplies one,
  issue an UPDATE so already-existing blank rows are corrected on next login.

Authelia-side emission of name/preferred_username is an operator concern
(claim mappings / userinfo scope in authelia config) — out of scope here.
The code now reads whatever claims are present and falls back sensibly.
2026-06-07 15:27:40 -04:00
Lucas Berger 28704132d0 fix(260607-l6l): add missing innerJoin to PATCH+DELETE event lookups
BUG 1: PATCH /:uid/edit and DELETE /:uid selected calendars.url/userId
from .from(calendarEvents) with no join, causing Drizzle to throw at
toSQL() time → 503. Added .innerJoin(calendars, ...) to both lookups,
mirroring the working GET / join idiom.

- Updated PATCH + DELETE beforeEach mocks to route through innerJoin→where
- Updated CR-01 PATCH test mock similarly
- Added regression: edit/delete lookups join calendars describe block with
  toSQL() assertions using vi.importActual (real drizzle, no DB needed)
- All 21 tests pass; typecheck clean
2026-06-07 15:25:31 -04:00
Lucas Berger a9d3de658e fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
BUG A — timed events written 4h off: EventForm sent a naive local wall-clock
string with no offset; the UTC API container parsed it via new Date() as UTC, so
09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new
apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC
instant in the browser (where the operator's zone is known); all-day stays a DATE
string. No backend change.

BUG B — created events attached to the wrong user's calendar + duplicate calendar
rows per poll: calendars had no unique key on url, and poller/sync matched
calendars by url alone — so under the shared single Fastmail account (D-16) one
member's collection resolved to the other member's row. Fix: composite
unique(user_id, url); scope poller lookup + sync select to (userId, url); hand
migration 0001 (dedup + add key), applied to the live DB.

Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140,
tsc clean both packages.
2026-06-06 22:32:10 -04:00
Lucas Berger 431ab31fb8 fix(api): serve the full ./public tree, not just /assets/*
Root-level PWA files (manifest.webmanifest, sw.js, registerSW.js, workbox-*.js,
icon-*.png, apple-touch-icon.png) were falling through to the index.html
catch-all and returning HTML — breaking the manifest (syntax error) and
preventing the service worker from ever registering. serveStatic('/*') serves
any existing file and calls next() for SPA routes, so index.html stays the
fallback. Registered after /health, /api/*, /callback so those still win.
2026-06-06 21:57:42 -04:00
Lucas Berger 237ec493aa feat(260606-tv8-01): add guarded GET /api/login route + tests
- Register app.get('/api/login', redirect to '/') in protected-routes block
- Route placed after OIDC guard so unauthenticated nav triggers auth flow
- Add login.test.ts covering bypass and OIDC-passthrough redirect paths
2026-06-06 21:35:52 -04:00
Lucas Berger 09fd1f2e92 feat(03-11): GREEN — re-read freshest calendarEvents etag before update PUT (WR-02)
- In update dispatch, SELECT etag FROM calendar_events WHERE uid = row.uid before PUT
- Use fresh etag as If-Match instead of stale enqueue-time row.etag when available
- Fall back to row.etag when calendarEvents has no matching row
- D-08 conflict detection intact: genuine external changes update calendarEvents.etag
  differently from any pending row, so they still 412 correctly
2026-06-05 21:06:32 -04:00
Lucas Berger b409c09e25 feat(03-11): GREEN — durable create-before-delete gating + drain concurrency guard (CR-04, CR-05)
- CR-04: delete rows with groupId query DB for sibling create status before dispatch
  - sibling 'pending': defer delete to later cycle (leave row pending)
  - sibling 'failed'/'dead': mark delete failed permanently (original event preserved, D-04)
  - sibling 'done': dispatch delete normally
- CR-05: module-level isDraining guard; overlapping 15s cycles are no-ops
  - SINGLE-PROCESS ONLY — documented limitation for multi-replica deployments
- Fix mockFromFn to use Symbol.for('drizzle:Name') instead of JSON.stringify (circular)
- Update D-04 ordering test to queue sibling-status mock response
2026-06-05 21:01:57 -04:00
Lucas Berger c21b040b36 feat(03-10): fail closed on bad credentials + fix backoff index + explicit randomUUID (CR-03, WR-01, WR-08)
- outboxWorker: remove empty-credential fallback; let loadClientForUser throw on error (CR-03)
- outboxWorker: fix backoff index from nextAttemptCount to row.attemptCount so first retry waits 15s not 60s (WR-01)
- events.ts: replace bare crypto.randomUUID() with import { randomUUID } from 'node:crypto' on all three handlers (WR-08)
2026-06-05 20:51:48 -04:00
Lucas Berger c03b47938e feat(03-10): wire buildVeventString into dispatch path + fix all-day DTEND+1 (CR-02, WR-04)
- outboxWorker: parse stored form JSON, build VCALENDAR via buildVeventString for create/update
- outboxWorker: return hardFail on payload parse error (corrupt payload never self-resolves)
- outboxWorker: import buildVeventString and RRULE_PRESETS from vevent.js
- vevent.ts: advance all-day DTEND by +1 calendar day (RFC-5545 exclusive end, WR-04 owning boundary)
2026-06-05 20:47:50 -04:00
Lucas Berger fac3a21332 feat(03-09): convert resolveUserId to async — real OIDC iss/sub→users.id via upsertUser (CR-06)
- Import upsertUser from auth/user.js
- resolveUserId now async: dev-bypass path unchanged; OIDC path calls getAuth
  then upsertUser(iss, sub, email) to resolve DB user id
- All 5 handlers (create, edit, delete, sync-status, writable-calendars) updated
  to await resolveUserId and 401 only when it returns null
- Remove all inline 'For now return 401' stubs and redundant getAuth calls
- grep confirms 0 'For now return 401' stubs remain; upsertUser imported+called
2026-06-05 20:41:20 -04:00
Lucas Berger 99cb1698a8 feat(03-09): rename eventFieldsSchema to canonical title/start/end contract (CR-01)
- Replace summary→title, dtstart→start, dtend→end in eventFieldsSchema
- Server now accepts exact CreateEventPayload shape the PWA sends
- Update existing write tests to use new canonical field names
- No internal rename map; one canonical name set end-to-end
- grep confirms no summary/dtstart/dtend in eventFieldsSchema
2026-06-05 20:38:27 -04:00
Lucas Berger 026aebccdf feat(03-04): wire startOutboxWorker into index.ts at boot
- Import startOutboxWorker beside startBrokerPoller import
- Call startOutboxWorker() immediately after startBrokerPoller()
- Worker drains D-05 outbox every 15s alongside the 5-min ctag poller
2026-06-05 18:20:50 -04:00
Lucas Berger cd4a8931e5 feat(03-04): implement outbox drain state machine (GREEN)
- runOutboxDrain: drains pending outbox rows, dispatches CalDAV writes
  via broker/write.ts, classifies HTTP responses per D-07/D-08
- CONFLICT_STATUS=412 routes to conflict flow: mark failed, re-sync (D-08)
- TRANSIENT_STATUSES: exponential backoff with MAX_ATTEMPTS=5 dead-letter (D-07)
- HARD_FAIL_STATUSES 400/401/403: fail immediately, no retry (D-07)
- Edit-as-move D-04: create row sorted before delete for same groupId;
  create-fail aborts the paired delete (T-03-14)
- triggerTargetedResync: fetches fresh DAVCalendars, calls syncCalendar (D-06)
- startOutboxWorker: node-cron */15 * * * * * schedule (15s interval)
- Fix test scaffold: vi.hoisted() for mock variables to resolve vitest
  hoisting TDZ issue; simplified mock chain to match and() single .where()
2026-06-05 18:20:18 -04:00
Lucas Berger 0a8222329e feat(03-03): implement write API surface — create/edit/delete + sync-status + writable-calendars
- POST /create: validates with zod, checks calendar ownership (D-03/T-03-06), enqueues pending outbox row, returns 202 with uid
- PATCH /:uid/edit: looks up event, checks ownership, enqueues update row; uses db.transaction for edit-as-move calendar pair (D-04)
- DELETE /:uid: looks up event, checks ownership, enqueues delete row with server-side etag (T-03-10)
- GET /sync-status: returns outbox status scoped to currentUser only (T-03-07/D-09)
- GET /writable-calendars: returns own personal + shared calendars, never other member's personal (D-03/T-03-11)
- Auth via dev-bypass (c.get('user')) + getAuth(c) fallback; 401 if neither
- No tsdav import — broker boundary enforced (D-12)
- All 69 events tests GREEN; tsc --noEmit clean
2026-06-05 17:58:01 -04:00
Lucas Berger a1243c1b83 feat(03-02): implement tsdav write wrappers (Task 2 GREEN) + fix vevent.ts TS type
write.ts:
- createCalendarEvent: wraps client.createCalendarObject with ${uid}.ics filename
- updateCalendarEvent: wraps client.updateCalendarObject with etag → If-Match (D-08)
- deleteCalendarEvent: wraps client.deleteCalendarObject with etag → If-Match (D-08)
- null etag passed as '' (safe; no crash, no spurious If-Match header)
- Returns raw Response; status code interpretation deferred to outboxWorker (D-07)
- All 6 write.test.ts assertions GREEN

vevent.ts fix:
- ICAL.Time constructor requires 2 args per TS types; pass ICAL.Timezone.localTimezone
  as zone param for all-day DATE values (isDate:true suppresses TZID regardless)
- tsc --noEmit passes clean
2026-06-05 17:48:12 -04:00
Lucas Berger b23b9597df feat(03-02): implement buildVeventString VEVENT builder (Task 1 GREEN)
- buildVeventString(NewEventParams) → { uid, icsString } using ical.js ICAL.Component
- All-day events use ICAL.Time({ isDate: true }) → VALUE=DATE, no TZID, no time (D-13)
- Timed events use ICAL.Time.fromJSDate(date, true) → DTSTART:...Z, no TZID (D-13)
- RRULE serialized via ICAL.Recur.fromString + ICAL.Property (prevents char-split bug)
- Exports: buildVeventString, NewEventParams, RRULE_PRESETS (daily/weekly/monthly/yearly)
- Uses crypto.randomUUID() for UID generation; appends @familysync suffix
- All 7 vevent.test.ts assertions GREEN
2026-06-05 17:46:19 -04:00
Lucas Berger 0c0bcefeef feat(03-01): populate calendarEvents.objectUrl in sync.ts from obj.url
- Set objectUrl: obj.url ?? null in both .values() and .onDuplicateKeyUpdate({ set: {} })
  alongside existing etag assignment — stores CalDAV object URL for If-Match on
  update/delete (D-08)
- All existing broker/sync tests pass (47 total)
2026-06-05 17:23:06 -04:00
Lucas Berger 78f0deefac feat(03-01): extend schema with calendarOutbox table + calendarEvents.objectUrl; install vite-plugin-pwa
- Add mysqlEnum import to drizzle-orm/mysql-core import block
- Add objectUrl varchar(1024) to calendarEvents after etag column (D-08)
- Add calendarOutbox table with status machine columns, groupId for edit-as-move (D-04)
- Add indexes: idx_outbox_user_status, idx_outbox_next_attempt, idx_outbox_uid
- Install vite-plugin-pwa@1.3.0 (supply-chain gate T-03-SC cleared by Task 1)
2026-06-05 17:22:37 -04:00
Lucas Berger 05d9f70b45 fix(02): window occurrences in UTC, not server-local timezone
ICAL.Time.fromJSDate(window, false) interpreted the UTC-midnight window bounds in the
server's local TZ (America/New_York in dev), shifting the window by the server offset and
dropping evening occurrences near a day window's end (e.g. June 11 17:45-04:00 = 21:45Z was
excluded from the June-11 day view). Use UTC so the window is deterministic and correct.
2026-06-05 15:28:26 -04:00
Lucas Berger 194f6a82a8 fix(02): show owner name / Family in event popover footer
Backend:
- expand.ts: add ownerName: string | null to CalendarOccurrence
  interface and expandOccurrences() signature; thread it onto every
  emitted occurrence.
- events.ts: SELECT users.displayName as ownerName in the join; pass
  it to expandOccurrences().

Frontend:
- client.ts: add ownerName: string | null to CalendarOccurrence.
- EventDetailPopover.tsx: render isShared ? 'Family' :
  (ownerName ?? calendarName) in the footer instead of calendarName.

Tests:
- expand.test.ts: pass ownerName to all expandOccurrences() calls;
  assert ownerName is carried onto occurrences in the DST test.
- events.test.ts: add ownerName to mock rows; assert ownerName present
  on occurrences; add ownerName assertion to timed-recurring test.
- EventDetailPopover.test.tsx: add ownerName to fixtures; split
  "calendar name in footer" into three targeted tests covering
  personal-with-owner, shared→Family, and null-owner fallback.
2026-06-05 15:14:43 -04:00
Lucas Berger 1f0b9546a8 fix(02): include all-day recurring masters in events route pre-filter
- Old filter: hasRrule=1 AND dtstartUtc < windowEnd
  All-day recurring masters have dtstartUtc=NULL so the comparison evaluates
  to NULL/false — 11 such rows in live cache were never returned
- New filter: hasRrule=1 AND (dtstartUtc < windowEnd OR dtstartDate < end)
  The OR covers all-day masters whose only date column is dtstartDate (DATE)
- expandOccurrences already does precise per-occurrence window checks, so
  over-selecting a master on the DATE path is safe
- Extend events.test.ts: assert timed recurring master (dtstart 2024) returns
  occurrences in 2026 window; assert all-day recurring master (dtstartDate 2024,
  dtstartUtc NULL) returns its 2026-06-15 occurrence
2026-06-05 14:50:27 -04:00
Lucas Berger f70496871a fix(02): populate hasRrule on every sync upsert so recurring masters are flagged
- Use ICAL.Event.isRecurring() (parity with expand.ts) to detect RRULE/RDATE
- Add hasRrule to .values() INSERT and .onDuplicateKeyUpdate() SET so the flag
  is set on first sync and self-heals on every subsequent re-sync
- Without this fix every event had has_rrule=0 (column default), causing the
  events route recurring-master pre-filter to return zero recurring occurrences
- Add sync.test.ts cases: hasRrule=true for timed+all-day recurring VEVENTs,
  hasRrule=false for non-recurring, and hasRrule in onDuplicateKeyUpdate.set
2026-06-05 14:50:18 -04:00
Lucas Berger ee2281fe81 fix(02): correct DURATION-only event end in expand.ts (BUG 1)
- Replace dtend ?? dtstart with event.endDate which handles DURATION-only VEVENTs
- Add positive-duration guard (PT30M / P1D) to both non-recurring and recurring branches
- Add single-duration.ics fixture and regression test asserting end > start for DURATION-only events
2026-06-05 14:33:00 -04:00