26 KiB
Phase 6: UX Polish — Pattern Map
Mapped: 2026-06-10 Files analyzed: 14 (all modifications to existing files; 0 net-new source files) Analogs found: 14 / 14 — every touch point has a direct in-repo exemplar
File Classification
| Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
apps/pwa/src/lib/eventDateTime.ts |
utility | transform | self (extend existing) | exact |
apps/pwa/src/lib/eventDateTime.test.ts |
test | transform | self (extend existing) | exact |
apps/pwa/src/components/EventForm.tsx |
component | request-response | self (extend existing) | exact |
apps/pwa/src/api/client.ts |
service | request-response | self (extend existing) | exact |
apps/pwa/src/api/client.test.ts |
test | request-response | apps/pwa/src/lib/loginRedirect.test.ts |
role-match |
apps/pwa/src/components/CalendarShell.tsx |
component | request-response | self (extend existing) | exact |
apps/pwa/src/lib/loginRedirect.ts |
utility | request-response | self (reference only) | exact |
apps/pwa/src/styles/tokens.css |
config | — | self (extend existing) | exact |
apps/pwa/src/components/PushPermissionPrompt.tsx |
component | — | self (remove redundancy) | exact |
apps/api/src/broker/expand.ts |
service | transform | self (extend existing) | exact |
apps/api/src/broker/vevent.ts |
service | transform | self (extend existing) | exact |
apps/api/src/broker/outboxWorker.ts |
service | CRUD | self (extend existing) | exact |
apps/api/tests/broker/vevent.test.ts |
test | transform | self (extend existing) | exact |
apps/api/tests/broker/expand.test.ts |
test | transform | self (extend existing) | exact |
Pattern Assignments
apps/pwa/src/lib/eventDateTime.ts (utility, transform) — D-04
Change: Add computeNewTimedEnd and computeNewAllDayEnd pure functions for end-tracking math.
Analog: same file — mirrors the existing serializeEventDateTime / localWallClockToUtcIso pattern exactly.
Existing function signature pattern (eventDateTime.ts:33–51):
export function serializeEventDateTime(
allDay: boolean,
startDate: string,
startTime: string,
endDate: string,
endTime: string,
): { start: string; end: string } {
if (allDay) {
return { start: startDate, end: endDate }
}
return {
start: localWallClockToUtcIso(startDate, startTime),
end: localWallClockToUtcIso(endDate, endTime),
}
}
Local accessor pattern (eventDateTime.ts:59–61):
export function localWallClockToUtcIso(date: string, time: string): string {
return new Date(`${date}T${time}:00`).toISOString()
}
New functions to add (copy the export + JSDoc style; use new Date(...) arithmetic inline — no third-party date lib):
/** Preserve timed-event duration on start change. Returns new { endDate, endTime }. */
export function computeNewTimedEnd(
newStartDate: string,
newStartTime: string,
oldStartDate: string,
oldStartTime: string,
oldEndDate: string,
oldEndTime: string,
): { endDate: string; endTime: string } {
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime()
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime()
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs)
return {
endDate: localDateISO(newEndDate), // helper below — local accessors only (WR-05 contract)
endTime: localTimeHHMM(newEndDate),
}
}
/** Preserve all-day day-span on start date change. Returns new endDate (inclusive). */
export function computeNewAllDayEnd(
newStartDate: string,
oldStartDate: string,
oldEndDate: string,
): string {
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
return addDaysISO(newStartDate, span)
}
WR-05 constraint: Date helpers MUST use local accessors (getFullYear/getMonth/getDate/getHours/getMinutes), never toISOString().slice(0,10) — that returns UTC date not local. See parseDateTime lines 107–133 for the established pattern.
apps/pwa/src/lib/eventDateTime.test.ts (test, transform) — D-04
Change: Extend with computeNewTimedEnd / computeNewAllDayEnd / floor-rule test cases.
Analog: same file — copy the describe/it/expect Vitest structure at lines 18–53.
Test structure to mirror (eventDateTime.test.ts:18–53):
import { describe, it, expect } from 'vitest'
import { serializeEventDateTime, localWallClockToUtcIso } from './eventDateTime.js'
describe('serializeEventDateTime (BUG A — write-path TZ)', () => {
it('serializes a timed start to a UTC instant (ends in Z)', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start.endsWith('Z')).toBe(true)
})
// ...
})
New describe block to add alongside:
describe('computeNewTimedEnd (D-04 — end-tracking)', () => {
it('preserves a 1-hour timed delta', () => { ... })
it('preserves a multi-day timed delta', () => { ... })
it('floors to 1h when old end was already behind old start', () => { ... })
})
describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', () => {
it('preserves a 0-day span (single day)', () => { ... })
it('preserves a 3-day span', () => { ... })
})
apps/pwa/src/components/EventForm.tsx (component, request-response) — D-04, D-06, D-07, D-08
Changes:
- Replace bare
onChangeon start date/time inputs with handlers that callcomputeNewTimedEnd/computeNewAllDayEnd(D-04). - Add
recurrenceBound: 'never'|'until'|'count',recurrenceUntil: string,recurrenceCount: numberstate and bound-type UI (D-06). - Add
hasRrule-gated series-edit confirmation trigger (D-08).
Exact bug sites (lines to replace):
Start date onChange — line 671:
// CURRENT (bug):
onChange={(e) => setStartDate(e.target.value)}
// REPLACE WITH a handler that also calls computeNewTimedEnd / computeNewAllDayEnd
onChange={(e) => {
const newStart = e.target.value
if (allDay) {
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate))
} else {
const { endDate: ed, endTime: et } = computeNewTimedEnd(newStart, startTime, startDate, startTime, endDate, endTime)
setEndDate(ed)
setEndTime(et)
}
setStartDate(newStart)
}}
Start time onChange — line 683 (same pattern but updates from time change):
// CURRENT (bug):
onChange={(e) => setStartTime(e.target.value)}
// REPLACE WITH handler that also recomputes end (timed only; allDay has no time)
Existing state declaration pattern to add new recurrence state alongside (EventForm.tsx:201–207):
const [startDate, setStartDate] = useState(initStart.date)
const [startTime, setStartTime] = useState(initStart.time)
const [endDate, setEndDate] = useState(initEndDate)
const [endTime, setEndTime] = useState(initEnd.time)
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none')
// ADD:
const [recurrenceBound, setRecurrenceBound] = useState<'never'|'until'|'count'>('never')
const [recurrenceUntil, setRecurrenceUntil] = useState('')
const [recurrenceCount, setRecurrenceCount] = useState(1)
Existing useEffect reset pattern to extend (EventForm.tsx:232–258):
The effect at lines 232–262 already resets all state when the form opens. Extend it to reset the three new recurrence-bound state variables to their defaults. Use the same setRecurrence(derivedRecurrence ?? 'none') pattern at line 258 as the model.
Payload construction pattern (the submit handler already at bottom of file builds CreateEventPayload):
// Existing pattern — extend it:
const payload: CreateEventPayload = {
title,
allDay,
start: serialized.start,
end: serialized.end,
...(isEditMode ? {} : { recurrence }),
...(location ? { location } : {}),
...(description ? { description } : {}),
...(calendarUrl ? { calendarUrl } : {}),
// ADD (D-06):
...(recurrence !== 'none' && recurrenceBound === 'until' && recurrenceUntil
? { recurrenceUntil }
: {}),
...(recurrence !== 'none' && recurrenceBound === 'count' && recurrenceCount >= 1
? { recurrenceCount }
: {}),
}
apps/pwa/src/api/client.ts (service, request-response) — D-06, D-11
Changes:
- Add
recurrenceUntil?andrecurrenceCount?toCreateEventPayload(D-06). - Add
hasRrule: booleantoCalendarOccurrence(D-08). - Add typed
SessionExpiredErrorclass (D-11). - Add
redirect: 'manual'+ opaqueredirect/401 detection to ALL fetch functions (D-11).
Existing CreateEventPayload type to extend (client.ts:136–148):
export interface CreateEventPayload {
title: string
allDay: boolean
start: string
end: string
recurrence?: RecurrencePreset
location?: string
description?: string
calendarUrl?: string
// ADD (D-06):
recurrenceUntil?: string // 'YYYY-MM-DD' — maps to RRULE UNTIL; undefined = no bound
recurrenceCount?: number // integer >= 1 — maps to RRULE COUNT; undefined = no bound
}
Existing CalendarOccurrence interface to extend (client.ts:71–91):
export interface CalendarOccurrence {
// ... all existing fields ...
description: string | null
// ADD (D-08):
hasRrule: boolean // true when this occurrence belongs to a recurring series
}
Existing auth detection pattern to generalize (client.ts:36–53 — fetchMe):
// CURRENT — only in fetchMe:
const res = await fetch('/api/me', {
credentials: 'include',
redirect: 'manual', // ← only fetchMe has this
})
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new Error('GET /api/me: authentication required') // ← untyped
}
// TARGET — typed error class + helper used by ALL fetch functions:
export class SessionExpiredError extends Error {
readonly name = 'SessionExpiredError'
constructor() {
super('Session expired — re-authentication required')
Object.setPrototypeOf(this, SessionExpiredError.prototype)
}
}
function handleAuthResponse(res: Response, label: string): void {
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new SessionExpiredError()
}
if (!res.ok) throw new Error(`${label} failed: ${res.status}`)
}
Every fetch function (fetchEvents, createEvent, updateEvent, deleteEvent, fetchSyncStatus, fetchWritableCalendars) gains redirect: 'manual' + handleAuthResponse(res, 'GET/POST/... /api/...'). Mirror the fetchMe call structure exactly.
apps/pwa/src/components/CalendarShell.tsx (component, request-response) — D-10, D-11
Changes:
- Replace the
meQuery.isError"Sign-in required" branch (lines 220–235) with anAuthSplashcomponent render (D-10). - Add
meQuery.isLoadingearly-return withAuthSplashso no skeleton paints before auth (D-10). - Wire the Zustand
sessionExpiredflag to anAuthSplashrender above the main tree (D-11).
Current "Sign-in required" branch to replace (CalendarShell.tsx:220–235):
// CURRENT — remove this entire block:
if (meQuery.isError) {
return (
<div
role="alert"
style={{
color: 'var(--color-destructive)',
padding: 'var(--space-4)',
...
}}
>
Sign-in required
</div>
)
}
// REPLACE WITH:
if (meQuery.isLoading) {
return <AuthSplash state="loading" />
}
if (meQuery.isError) {
// useEffect at line 197 calls maybeRedirectToLogin() — splash shows while redirect fires
return <AuthSplash state="redirecting" />
}
Existing useEffect pattern for auth redirect to keep (CalendarShell.tsx:196–208):
// Keep these two effects — they handle the one-shot guard correctly:
useEffect(() => {
if (meQuery.isError) {
maybeRedirectToLogin()
}
}, [meQuery.isError])
useEffect(() => {
if (meQuery.isSuccess) {
clearLoginRedirect()
}
}, [meQuery.isSuccess])
Existing import pattern to extend (CalendarShell.tsx:44–56):
import { fetchMe, fetchEvents } from '../api/client.js'
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js'
// ADD:
import { SessionExpiredError } from '../api/client.js'
import { AuthSplash } from './AuthSplash.js' // new component
D-11 TanStack Query v5 global error handler — wire in App.tsx (or wherever QueryClient is created), NOT in CalendarShell. Pattern is queryClient.getQueryCache().subscribe(...) and queryClient.getMutationCache().subscribe(...). Planner must verify exact TanStack Query v5 API via Context7 before coding. The semantic intent:
queryClient.getQueryCache().subscribe((event) => {
if (event.type === 'error' && event.error instanceof SessionExpiredError) {
setSessionExpired(true) // Zustand flag
}
})
queryClient.getMutationCache().subscribe((event) => {
if (event.type === 'error' && event.error instanceof SessionExpiredError) {
setSessionExpired(true)
}
})
apps/pwa/src/lib/loginRedirect.ts (utility) — D-11
No changes to the file itself. The existing maybeRedirectToLogin() and clearLoginRedirect() functions are reused as-is. The D-11 session-expiry path must call clearLoginRedirect() BEFORE calling maybeRedirectToLogin() so the one-shot guard fires fresh. This is already the pattern for the meQuery.isSuccess path at CalendarShell.tsx:205.
Reference (loginRedirect.ts:28–45):
export function maybeRedirectToLogin(): boolean {
if (typeof window === 'undefined') return false
try {
if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) return false
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1')
window.location.href = '/api/login'
return true
} catch { return false }
}
export function clearLoginRedirect(): void {
try { sessionStorage.removeItem(LOGIN_REDIRECT_KEY) } catch { }
}
apps/pwa/src/styles/tokens.css (config) — D-13
Change: Add missing @keyframes pulse. Remove nothing (the @keyframes spin at lines 140–147 stays).
Existing @keyframes spin to copy the CSS pattern from (tokens.css:140–147):
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
Add directly after it:
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
Also add @keyframes shimmer is already present at lines 131–138. Follow the same format (no vendor prefixes, no animation-fill-mode in the keyframe block itself).
apps/pwa/src/components/PushPermissionPrompt.tsx (component) — D-13
Change: Remove the redundant local <style> block at lines 358–363. No other changes.
Block to delete (PushPermissionPrompt.tsx:357–363):
{/* Spin animation for loader */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
The tokens.css global definition (loaded via main.tsx → index.css → @import './tokens.css') already covers this. The animation: 'spin 1s linear infinite' inline style in this component continues to work unchanged.
apps/api/src/broker/expand.ts (service, transform) — D-08
Change: Add hasRrule: boolean to CalendarOccurrence interface and populate it in expandOccurrences.
Interface to extend (expand.ts:37–67):
export interface CalendarOccurrence {
id: string
uid: string
calendarId: number
// ... all existing fields ...
description: string | null
// ADD:
hasRrule: boolean // true when this event has an RRULE (recurring series)
}
Population pattern — in expandOccurrences, event.isRecurring() is already called at line 223 for the non-recurring branch. Capture it once before the branch, then pass to each occurrences.push(...):
// BEFORE the branch at line 223:
const isRecurring = event.isRecurring()
// In the non-recurring push at lines 241–256 — add:
occurrences.push({
// ... existing fields ...
hasRrule: isRecurring, // always false here — non-recurring branch
})
// In the recurring push at lines 287–302 — add:
occurrences.push({
// ... existing fields ...
hasRrule: isRecurring, // always true here — recurring branch
})
Existing push pattern to mirror (expand.ts:241–257):
occurrences.push({
id: makeOccurrenceId(uid, dtstart),
uid,
calendarId,
calendarName,
ownerUserId,
ownerName,
color,
isShared,
title: event.summary ?? '',
start,
end,
allDay,
location: event.location ?? null,
description: event.description ?? null,
// ADD: hasRrule: isRecurring
})
apps/api/src/broker/vevent.ts (service, transform) — D-06
Change: Extend RRULE serialization to support UNTIL (DATE and DATETIME forms) and COUNT. No interface changes to NewEventParams are strictly required — callers assemble the rruleString before passing it. The assembly logic lives in outboxWorker.ts.
Existing RRULE serialization pattern to keep unchanged (vevent.ts:143–148):
// This pattern handles any valid RRULE string — UNTIL/COUNT included:
if (params.rruleString) {
const recur = ICAL.Recur.fromString(params.rruleString)
const rruleProp = new ICAL.Property('rrule')
rruleProp.setValue(recur)
vevent.addProperty(rruleProp)
}
RRULE_PRESETS map to keep unchanged (vevent.ts:49–54):
export const RRULE_PRESETS: Record<string, string> = {
daily: 'FREQ=DAILY',
weekly: 'FREQ=WEEKLY',
monthly: 'FREQ=MONTHLY',
yearly: 'FREQ=YEARLY',
}
The UNTIL/COUNT string assembly happens in outboxWorker.ts (see below). buildVeventString receives the complete rruleString and serializes it correctly via ICAL.Recur.fromString — verified in RESEARCH.md.
apps/api/src/broker/outboxWorker.ts (service, CRUD) — D-06
Changes:
- Read
recurrenceUntilandrecurrenceCountfrom the validated payload. - Add
assembleRruleStringhelper that appends;COUNT=Nor;UNTIL=YYYYMMDD[T235959Z]to the base preset string. - On series edit, when only the bound changes (no new
recurrencepreset), parse the preserved RRULE and add/replace the bound modifier.
Existing outboxPayloadSchema to extend (outboxWorker.ts:71–83):
const outboxPayloadSchema = z
.object({
title: z.string().min(1).max(255),
allDay: z.boolean(),
start: z.string().min(1).max(64),
end: z.string().min(1).max(64),
location: z.string().max(2000).optional(),
description: z.string().max(2000).optional(),
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
calendarUrl: z.string().url().max(1024).optional(),
_preservedRrule: z.string().max(1024).optional(),
// ADD (D-06):
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD'
recurrenceCount: z.number().int().min(1).optional(),
})
.passthrough()
Existing RRULE assembly site to extend (outboxWorker.ts:255–308):
// Existing (keep):
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
// ADD: assemble final rruleString with optional bound modifier
function assembleRruleString(
basePreset: string, // e.g. 'FREQ=WEEKLY' from RRULE_PRESETS
until?: string, // 'YYYY-MM-DD'
count?: number,
allDay?: boolean,
): string {
let s = basePreset
if (count !== undefined) {
s += `;COUNT=${count}`
} else if (until) {
if (allDay) {
s += `;UNTIL=${until.replace(/-/g, '')}` // DATE form: 20260630
} else {
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z` // DATETIME UTC: 20260630T235959Z
}
}
return s
}
// Then pass to buildVeventString:
const finalRruleString = hasExplicitRecurrence && rruleFromPayload
? assembleRruleString(rruleFromPayload, fields.recurrenceUntil, fields.recurrenceCount, fields.allDay)
: (preservedRrule
// Series edit with bound change only: parse + modify preserved RRULE
? (fields.recurrenceUntil || fields.recurrenceCount !== undefined
? assembleRruleString(
// Strip any existing UNTIL/COUNT from the preserved rule first
preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, ''),
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
)
: preservedRrule)
: rruleFromPayload)
apps/api/tests/broker/vevent.test.ts (test, transform) — D-06
Change: Add test cases for UNTIL (DATE form), UNTIL (DATETIME UTC form), COUNT.
Existing test structure to extend (vevent.test.ts:22–60):
import { describe, it, expect } from 'vitest'
import { buildVeventString } from '../../src/broker/vevent.js'
describe('buildVeventString', () => {
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
const result = buildVeventString({ summary: 'Team standup', allDay: false, ... })
expect(result.icsString).toContain('RRULE:')
})
Add alongside existing cases:
it('serializes COUNT in RRULE for a timed event', () => {
const result = buildVeventString({
summary: 'Weekly',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;COUNT=5',
})
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5')
})
it('serializes UNTIL as DATE form for all-day events', () => {
const result = buildVeventString({
summary: 'Daily standup',
allDay: true,
dtstart: '2026-06-10',
dtend: '2026-06-11',
rruleString: 'FREQ=DAILY;UNTIL=20260630',
})
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630')
expect(result.icsString).not.toContain('T235959Z')
})
it('serializes UNTIL as DATETIME UTC form for timed events', () => {
const result = buildVeventString({
summary: 'Weekly',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;UNTIL=20260630T235959Z',
})
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z')
})
apps/api/tests/broker/expand.test.ts (test, transform) — D-06, D-08
Change: Add hasRrule assertions to existing recurring expansion tests; add bounded RRULE test case.
Existing test file (tests/broker/expand.test.ts) — add assertions to any test that calls expandOccurrences with a recurring event:
// Pattern: each occurrence in the result must have hasRrule set
const occs = expandOccurrences(rawVevent, windowStart, windowEnd, ...)
expect(occs[0].hasRrule).toBe(true) // recurring event
// For non-recurring:
expect(occs[0].hasRrule).toBe(false)
// For bounded RRULE: correct occurrence count
const boundedRrule = 'FREQ=WEEKLY;COUNT=3'
// inject into rawVevent → expandOccurrences → length === 3 within a wide window
Shared Patterns
Auth detection — redirect: 'manual' + typed error
Source: apps/pwa/src/api/client.ts lines 36–53 (fetchMe)
Apply to: All fetch functions in client.ts (D-11)
The existing fetchMe pattern is the model for generalizing:
const res = await fetch('/api/me', {
credentials: 'include',
redirect: 'manual', // ← add to every fetch call
})
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new Error('GET /api/me: authentication required') // ← replace with SessionExpiredError
}
if (!res.ok) {
throw new Error(`GET /api/me failed: ${res.status}`)
}
One-shot login redirect
Source: apps/pwa/src/lib/loginRedirect.ts lines 28–64
Apply to: CalendarShell.tsx auth splash (D-10), session-expiry interstitial (D-11)
Rule: always call clearLoginRedirect() before maybeRedirectToLogin() in the D-11 (session-expiry) path so the guard fires fresh. The D-10 (cold load) path keeps the existing useEffect pattern unchanged.
Zod schema extension
Source: apps/api/src/broker/outboxWorker.ts lines 71–83 (outboxPayloadSchema)
Apply to: outboxPayloadSchema (D-06) AND eventFieldsSchema in apps/api/src/routes/events.ts (same two new fields must be added to the route-level schema)
Mirror the .optional() pattern already used for location, description, calendarUrl.
ICS RRULE serialization — ICAL.Recur.fromString + rruleProp.setValue
Source: apps/api/src/broker/vevent.ts lines 143–148
Apply to: All RRULE assembly in outboxWorker.ts (D-06)
Never concatenate raw RRULE strings into the ICS via addPropertyWithValue('rrule', string) — that serializes character-by-character. Always go through ICAL.Recur.fromString(rruleString) + rruleProp.setValue(recur).
CalendarOccurrence interface atomicity
Source: apps/api/src/broker/expand.ts:37–67 (server) + apps/pwa/src/api/client.ts:71–91 (client)
Apply to: hasRrule addition (D-08)
Both interfaces are mirrored manually (no codegen). Update them in the same commit. The server expand.ts type is the source of truth; client.ts is the consumer mirror. Pitfall 4 in RESEARCH.md documents this.
Pure-function test structure
Source: apps/pwa/src/lib/eventDateTime.test.ts lines 15–53
Apply to: New computeNewTimedEnd / computeNewAllDayEnd tests (D-04)
Copy the import { describe, it, expect } from 'vitest' header and describe('...', () => { it('...', () => { ... }) }) structure exactly. Tests run with cd apps/pwa && pnpm test -- lib/eventDateTime.
No Analog Found
All touched files have direct in-repo analogs. One net-new component is implied:
| Implied New File | Role | Data Flow | Reason |
|---|---|---|---|
apps/pwa/src/components/AuthSplash.tsx |
component | request-response | No existing full-screen auth splash component; closest analog is SkeletonCalendar.tsx (full-screen centered loading state) |
AuthSplash analog: apps/pwa/src/components/SkeletonCalendar.tsx — a full-screen centered loading component. Copy its layout structure and inline-style approach. The AuthSplash variant renders "Signing you in…" (state="loading") or "Your session expired — signing you back in…" (state="redirecting") with the existing spin animation token.
Metadata
Analog search scope: apps/pwa/src/, apps/api/src/broker/, apps/api/tests/broker/
Files read: 14 source files + 2 test files
Pattern extraction date: 2026-06-10