Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
32 KiB
phase, verified, status, score, overrides_applied, human_verification
| phase | verified | status | score | overrides_applied | human_verification | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-ux-polish | 2026-06-10T20:37:54Z | human_needed | 12/12 | 0 |
|
Phase 06: UX Polish Verification Report
Phase Goal: Smooth the rough edges surfaced during live use — clearer all-day events, saner event-form date/recurrence behavior, recurring-series editing, and auth-flow polish — so the app feels slick for the non-technical Apple member (hard UX constraint). Verified: 2026-06-10T20:37:54Z Status: human_needed Re-verification: No — initial verification
Goal Achievement
Observable Truths
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | Moving an event's start moves its end preserving duration; end never strands behind start (D-03/D-04) | VERIFIED | computeNewTimedEnd + computeNewAllDayEnd exported from eventDateTime.ts; wired in EventForm.tsx start onChange handlers at lines 729-760; 6 unit tests green; EventForm tests D-04 timed + all-day pass |
| 2 | A recurring series can be bounded via "Ends: Never / On date / After N times" (D-06) | VERIFIED | assembleRruleString in outboxWorker.ts; recurrenceUntil/recurrenceCount Zod fields in both events.ts and outboxWorker.ts; "Ends" control in EventForm.tsx (state at lines 214-216, rendered at line 888+); 7 assembleRruleString tests green; playwright-cli verified |
| 3 | Editing a recurring occurrence prompts "Edit recurring series" before saving (D-08/D-09) | VERIFIED | SeriesEditPrompt.tsx created with role="dialog", aria-modal, focus trap, Escape=cancel, correct copy; EventForm.tsx gates Save on occurrence?.hasRrule === true at line 419; hasRrule populated in expand.ts + mirrored in client.ts; playwright-cli verified |
| 4 | All-day events visually distinct from timed events at a glance (999.6/D-12) | VERIFIED | .sx__date-grid .sx__date-grid-event and .sx__month-grid-day__events .sx__month-grid-event:not(:has(.sx__month-grid-event-time)) CSS rules in index.css (lines 125-140) with border-radius:4px, font-weight:600, border-inline-start:none; real Schedule-X v4.6.0 selectors (not the non-existent .sx__all-day-event) verified correct after follow-up fix 6dbb166; playwright-cli verified |
| 5 | All-day edit off-by-one stays fixed — re-editing does not grow event by a day (D-05) | VERIFIED | exclusiveEndToInclusiveDate pre-fill at EventForm reset line intact; D-05 round-trip test in EventForm.test.tsx passes; playwright-cli verified |
| 6 | Unauthenticated cold load shows only the neutral "Signing you in" splash — no calendar/skeleton/alert flash (D-10) | VERIFIED | CalendarShell.tsx returns <AuthSplash state="loading" /> on meQuery.isLoading before any calendar content (line 269); AuthSplash.tsx created with role="status", correct copy, full-screen centered layout; playwright-cli checkpoint PASS for desktop Chromium |
| 7 | A session that expires mid-use shows "Session expired" interstitial and cleanly redirects (D-11) | VERIFIED | SessionExpiredError class in client.ts; handleAuthResponse covers all 7 fetch wrappers; QueryCache/MutationCache onError in main.tsx (not defaultOptions.onError); sessionExpired flag in calendarStore.ts; CalendarShell renders <AuthSplash state="redirecting" /> on sessionExpired=true with 1.5s redirect; dead-end state reachable when guard exhausted (follow-up fix e392c69); playwright-cli checkpoint PASS for desktop Chromium |
| 8 | Sync indicators actually animate — SyncStateToast spinner spins and LiveSyncIndicator reconnecting dot pulses (D-13) | VERIFIED | @keyframes pulse added to tokens.css at line 149 (0%,100% opacity:1; 50% opacity:0.4); redundant local @keyframes spin block removed from PushPermissionPrompt.tsx (confirmed absent); playwright-cli checkpoint PASS — both animationName values non-'none' in desktop Chromium |
| 9 | Nav chrome persists on /lists — BottomTabBar does not overlap Settings on desktop (UAT fixes FIX-3/FIX-4) | VERIFIED | AppNav lifted to App.tsx as a persistent sibling of <Routes> (outside any Route, line 112); BottomTabBar returns null on desktop via isPhone() guard (line 56); AppNav persistence test and BottomTabBar hidden-on-desktop test both green |
| 10 | D-04 floor rule: end snaps to newStart+1h (timed) / same day (all-day) when old end was already behind start | VERIFIED | deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60*60*1000 in computeNewTimedEnd; Math.max(0, dateDiffDays(...)) in computeNewAllDayEnd; two floor-rule unit tests green |
| 11 | RRULE UNTIL value-type matches DTSTART — DATE form for all-day, DATETIME UTC for timed (D-06, RFC 5545) | VERIFIED | assembleRruleString: all-day emits UNTIL=YYYYMMDD, timed emits UNTIL=YYYYMMDDTHHMMSSZ (T235959Z); three vevent.test.ts serialization assertions + five assembleRruleString unit tests green |
| 12 | FREQ=DAILY regression locked (D-07) | VERIFIED | FREQ persistence (D-07 regression) test in outboxWorker.test.ts asserts daily-recurrence payload emits RRULE:FREQ=DAILY; green |
Score: 12/12 truths verified
Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
apps/pwa/src/lib/eventDateTime.ts |
computeNewTimedEnd + computeNewAllDayEnd exports with floor rules; no toISOString().slice |
VERIFIED | Both functions exported at lines 114/142; WR-05 compliance confirmed — no toISOString().slice in helper code |
apps/pwa/src/lib/eventDateTime.test.ts |
6 new tests: 3 timed + 3 all-day end-tracking, RED→GREEN | VERIFIED | 6 tests present in two describe blocks; computeNewTimedEnd + computeNewAllDayEnd imported; all pass |
apps/api/src/broker/outboxWorker.ts |
assembleRruleString exported; recurrenceUntil/recurrenceCount in outboxPayloadSchema |
VERIFIED | assembleRruleString exported at line 114; both fields at lines 83-84 |
apps/api/src/routes/events.ts |
eventFieldsSchema accepts recurrenceUntil + recurrenceCount |
VERIFIED | Both fields at lines 111-112 |
apps/api/tests/broker/vevent.test.ts |
UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions | VERIFIED | 3 assertions match verified ical.js 2.2.1 output strings |
apps/api/tests/broker/outboxWorker.test.ts |
assembleRruleString describe + FREQ persistence test | VERIFIED | Both describe blocks present; 7+1 tests pass |
apps/api/src/broker/expand.ts |
hasRrule: boolean on CalendarOccurrence; populated from event.isRecurring() in both push sites |
VERIFIED | Field at line 68; const isRecurring capture at line 224; both push sites at lines 261/308 |
apps/api/tests/broker/expand.test.ts |
hasRrule true/false assertions + bounded COUNT=3 invariant |
VERIFIED | hasRrule describe with 2 tests + Bounded RRULE describe with 3 tests; all pass |
apps/api/tests/fixtures/weekly-count3.ics |
Bounded fixture for COUNT=3 test | VERIFIED | File exists at apps/api/tests/fixtures/weekly-count3.ics |
apps/pwa/src/styles/tokens.css |
@keyframes pulse added globally |
VERIFIED | Present at line 149; exactly once |
apps/pwa/src/components/PushPermissionPrompt.tsx |
Redundant @keyframes spin <style> block removed |
VERIFIED | grep -q '@keyframes spin' returns nothing |
apps/pwa/src/api/client.ts |
SessionExpiredError; handleAuthResponse; redirect:'manual' on all wrappers; hasRrule on CalendarOccurrence; recurrenceUntil/recurrenceCount on CreateEventPayload |
VERIFIED | All present: class SessionExpiredError at line 33; handleAuthResponse at line 51; 7 handleAuthResponse call sites; hasRrule at line 131; recurrenceUntil at line 190 |
apps/pwa/src/api/client.test.ts |
SessionExpiredError detection tests (opaqueredirect + 401 per wrapper; 500 = generic Error) | VERIFIED | 40 tests pass; opaqueredirect/401/500 cases for fetchEvents, createEvent, updateEvent, deleteEvent, fetchMe |
apps/pwa/src/components/AuthSplash.tsx |
Full-screen interstitial; loading/redirecting/dead-end states; role="status" |
VERIFIED | File created; AuthSplashState type at line 27; role="status" at line 61; all three states handled |
apps/pwa/src/components/CalendarShell.tsx |
meQuery.isLoading → AuthSplash loading; meQuery.isError → AuthSplash redirecting/dead-end; content only on isSuccess; sessionExpired interstitial wiring |
VERIFIED | Lines 269-281 gate render; sessionExpired effect at lines 231-243; enabled: meQuery.isSuccess at line 120 |
apps/pwa/src/main.tsx |
QueryCache/MutationCache onError (NOT defaultOptions.onError) routing SessionExpiredError to setSessionExpired |
VERIFIED | QueryCache at line 37; MutationCache at line 38; no defaultOptions.onError in file |
apps/pwa/src/store/calendarStore.ts |
sessionExpired: boolean + setSessionExpired action |
VERIFIED | sessionExpired: false default at line 159; setSessionExpired at line 187 |
apps/pwa/src/components/EventForm.tsx |
Start onChange handlers call computeNewTimedEnd/computeNewAllDayEnd; recurrenceBound state + "Ends" control; hasRrule gates SeriesEditPrompt; payload sends recurrenceUntil/recurrenceCount |
VERIFIED | computeNewTimedEnd wired at lines 732/755; computeNewAllDayEnd at line 730; recurrenceBound state at line 214; "Ends" control at line 888; hasRrule gate at line 419; payload spread at lines 399-403 |
apps/pwa/src/components/SeriesEditPrompt.tsx |
Bottom-sheet/dialog; focus trap; Escape=cancel; exact UI-SPEC copy; accent-filled "Update series"; ghost "Cancel" | VERIFIED | File created; role="dialog", aria-modal at lines 135-136; "Edit recurring series" at line 154; "Update series" at line 217 |
apps/pwa/src/styles/index.css |
.sx__date-grid-event + .sx__month-grid-event:not(:has(.sx__month-grid-event-time)) all-day pill overrides (real v4.6.0 selectors) |
VERIFIED | Both rules at lines 125/134 with border-radius:4px, font-weight:600, border-inline-start:none |
apps/pwa/src/App.tsx |
AppNav as persistent sibling of <Routes> (FIX 3) |
VERIFIED | <AppNav> rendered at line 112, outside <Routes> which starts at line 121 |
apps/pwa/src/components/BottomTabBar.tsx |
Returns null on desktop (FIX 4) |
VERIFIED | if (!isPhone()) return null at line 56 |
Key Link Verification
| From | To | Via | Status | Details |
|---|---|---|---|---|
EventForm.tsx |
eventDateTime.ts |
start onChange → computeNewTimedEnd / computeNewAllDayEnd |
WIRED | Both imports at lines 45-46; both calls in onChange handlers at lines 730, 732, 755 |
EventForm.tsx |
client.ts |
payload carries recurrenceUntil/recurrenceCount; occurrence.hasRrule gates prompt |
WIRED | recurrenceUntil spread at line 399; recurrenceCount spread at line 402; hasRrule check at line 419 |
main.tsx |
calendarStore.ts |
QueryCache/MutationCache onError → setSessionExpired(true) on SessionExpiredError |
WIRED | useCalendarStore.getState().setSessionExpired(true) at line 32; imperative store access confirmed |
CalendarShell.tsx |
AuthSplash.tsx |
meQuery.isLoading/isError and sessionExpired flag render AuthSplash |
WIRED | Imports at line 50; <AuthSplash state="loading" /> at line 270; <AuthSplash state="redirecting" /> at lines 281/291 |
outboxWorker.ts |
vevent.ts |
assembleRruleString result passed to buildVeventString |
WIRED | assembleRruleString called at lines 360/371/451/462; result flows as rruleString into the dispatch path |
events.ts |
outboxWorker.ts |
recurrenceUntil/recurrenceCount in enqueued payload |
WIRED | Zod schema accepts fields in both eventFieldsSchema (events.ts:111-112) and outboxPayloadSchema (outboxWorker.ts:83-84) |
expand.ts |
client.ts (mirror) |
CalendarOccurrence.hasRrule server source-of-truth mirrored |
WIRED | hasRrule: boolean at expand.ts line 68 (authoritative); mirrored at client.ts line 131 with explicit comment |
Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|---|---|---|---|---|
EventForm.tsx |
computeNewTimedEnd result → endDate/endTime state |
eventDateTime.ts pure functions over form state (no network) |
Yes — deterministic math, no network fetch, no empty source | FLOWING |
EventForm.tsx |
recurrenceUntil/recurrenceCount → submit payload |
User input (controlled form state) | Yes — user input flows directly to payload spread | FLOWING |
EventForm.tsx |
occurrence.hasRrule gate |
CalendarOccurrence from parent prop (occurrence fetched from API via fetchEvents) |
Yes — hasRrule populated server-side in expandOccurrences from event.isRecurring() |
FLOWING |
CalendarShell.tsx |
meQuery.isLoading/isError |
TanStack Query ['me'] query → fetchMe() → /api/me |
Yes — real API call with redirect:'manual'; auth gating is live |
FLOWING |
CalendarShell.tsx |
sessionExpired |
Zustand store, set by QueryCache/MutationCache onError on real SessionExpiredError |
Yes — fires on real 401/opaqueredirect from any query/mutation | FLOWING |
Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
computeNewTimedEnd + computeNewAllDayEnd exported |
node -e "import('./src/lib/eventDateTime.ts').then(m => console.log(typeof m.computeNewTimedEnd, typeof m.computeNewAllDayEnd))" |
function function |
PASS |
| D-04 end-tracking unit tests pass | pnpm --filter @familysync/pwa test -- run lib/eventDateTime |
191/191 pass | PASS |
| SessionExpiredError detection tests pass | npx vitest run src/api/client.test.ts (apps/pwa) |
40/40 pass | PASS |
| assembleRruleString + FREQ persistence tests pass | npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts (apps/api) |
39/39 pass | PASS |
| hasRrule + bounded RRULE tests pass | npx vitest run tests/broker/expand.test.ts (apps/api) |
10/10 pass | PASS |
| Full PWA test suite green | pnpm --filter @familysync/pwa test -- run |
191/191 pass (17 files) | PASS |
| All phase-06 API broker + events tests green | npx vitest run tests/broker/ tests/routes/events.test.ts (apps/api) |
114/114 pass (9 files) | PASS |
Note: tests/routes/lists.test.ts and tests/routes/push.test.ts fail with ER_ACCESS_DENIED_ERROR (MariaDB not running with password in current dev environment). These are pre-existing integration-test DB-connectivity failures, not regressions introduced by phase 06. All broker tests that phase 06 modified or created are green.
Probe Execution
No probe scripts found (scripts/*/tests/probe-*.sh absent). Step 7c: SKIPPED.
Requirements Coverage
No v1 REQ-IDs were assigned to this phase (confirmed by phase description and plan frontmatter — requirements: [] in all plans). Phase is tracked against backlog items 999.2/3/6/7/8/9 and locked decisions D-01..D-13. All backlog items verified via truth/artifact checks above.
Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|---|---|---|---|---|
SeriesEditPrompt.tsx |
86 | if (!open) return null |
Info | Correct conditional render guard — component is fully substantive when open === true; not a stub |
No TBD, FIXME, or XXX markers found in any phase-06-modified file. No unreferenced debt markers.
Human Verification Required
1. iOS/Standalone Cold-Load and OIDC Redirect (D-10/D-11)
Test: Install the PWA to iOS Home Screen. Cold-load with no session cookie. Confirm the first painted frame is the neutral "Signing you in" splash (not the calendar shell or "Sign-in required" alert), and that Authelia redirect completes correctly in standalone mode. Then simulate a session expiry to confirm the "Session expired / Signing you back in..." interstitial appears and redirects to /api/login without hanging.
Expected: Splash shown on cold-load; Authelia round-trip succeeds; mid-use 401 shows interstitial then redirects within ~2s; no redirect loop.
Why human: iOS Safari standalone OIDC redirect behavior cannot be driven by playwright-cli. This is the documented CLAUDE.md exception (standalone-mode OIDC redirect, window.location.href cross-origin fallback behavior). Recorded in 06-VALIDATION.md Manual-Only table. Desktop Chromium checkpoints already PASS (playwright-cli verified in 06-05).
2. PushPermissionPrompt Spinner on iOS Device (CP-04.3)
Test: On a real iOS device with the PWA installed as a standalone app, trigger the push permission prompt and confirm the Loader2 spinner rotates.
Expected: Spinner rotates using the global @keyframes spin from tokens.css (the redundant local redefinition was removed in commit 81f2678).
Why human: PushPermissionPrompt only surfaces inside an installed iOS/standalone PWA. No desktop Chromium path to the component. Code-confirmed: the inline animation: 'spin 1s linear infinite' is still present on the Loader2 element and resolves to the global keyframe. A real-device spot-check is required for confidence. Documented residual in 06-04-SUMMARY.md.
Notable Deviations from Plan (Not Gaps)
The following deviations were auto-fixed during execution and do not constitute gaps:
-
Schedule-X CSS selector correction (06-06): Plan assumed
.sx__all-day-eventbut Schedule-X v4.6.0 does not emit that class. Executor discovered and fixed in commits6dbb166+5620261using real v4.6.0 selectors. playwright-cli re-verified PASS. -
AuthSplash dead-end state + redirect guard persistence (06-05): Initial implementation had the dead-end state unreachable and the one-shot guard cleared prematurely. Found during playwright-cli checkpoint; fixed in commits
36ef7a0+e392c69. Re-verified PASS. -
hasExplicitRecurrenceprecedence bug (06-02):recurrence:'none'with_preservedRrulepresent incorrectly fell through to emit an RRULE. Fixed in GREEN commitd2abb91. Regression testCR-01confirms the fix. -
EventDetailPopover.test.tsxfixture update (06-06): Existing test fixtures omitted the new requiredhasRrulefield. Mechanical fix in commit69e5ae8. -
Dev-seed gap: The dev-bypass user (id 1) has no CalDAV credential/calendars (those belong to user 2), so live event-create-via-form could not be exercised end-to-end against Fastmail. Server logic and form UI verified via route-mocks and direct DB occurrence inserts. Not a code defect.
Gaps Summary
No gaps. All 12 truths are VERIFIED. The two human verification items are device-only constraints (iOS/standalone behavior) that were explicitly pre-classified as manual checkpoints in 06-VALIDATION.md before execution began.
Verified: 2026-06-10T20:37:54Z Verifier: Claude (gsd-verifier)