From 194f6a82a825fda27663039b9db3f30ebcf1f38d Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 15:14:43 -0400 Subject: [PATCH] fix(02): show owner name / Family in event popover footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/broker/expand.ts | 12 +++++++++++ apps/api/src/routes/events.ts | 2 ++ apps/api/tests/broker/expand.test.ts | 7 +++++++ apps/api/tests/routes/events.test.ts | 15 +++++++++---- apps/pwa/src/api/client.ts | 6 ++++++ .../components/EventDetailPopover.test.tsx | 21 ++++++++++++++++++- .../pwa/src/components/EventDetailPopover.tsx | 8 +++++-- 7 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts index a38f58b..2c003f8 100644 --- a/apps/api/src/broker/expand.ts +++ b/apps/api/src/broker/expand.ts @@ -45,6 +45,13 @@ export interface CalendarOccurrence { * Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars. */ ownerUserId: number + /** + * Display name of the calendar owner (users.displayName). + * Null when the user has not set a display name. + * The popover uses this to show the owner name for personal events; + * falls back to calendarName when null. + */ + ownerName: string | null /** Hex color: users.color for personal calendars, '#F25C7A' for shared-family */ color: string /** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */ @@ -67,6 +74,8 @@ export interface OccurrenceMeta { calendarId: number calendarName: string ownerUserId: number + /** Display name of the calendar owner; null when users.displayName is not set */ + ownerName: string | null /** Hex color: pre-computed by the route (users.color or shared-family constant) */ color: string isShared: boolean @@ -162,6 +171,7 @@ export function expandOccurrences( calendarId: number, calendarName: string, ownerUserId: number, + ownerName: string | null, color: string, isShared: boolean, ): CalendarOccurrence[] { @@ -229,6 +239,7 @@ export function expandOccurrences( calendarId, calendarName, ownerUserId, + ownerName, color, isShared, title: event.summary ?? '', @@ -274,6 +285,7 @@ export function expandOccurrences( calendarId, calendarName, ownerUserId, + ownerName, color, isShared, title: event.summary ?? '', diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index a2ce837..b3505a1 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -82,6 +82,7 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { isShared: calendars.isShared, userId: users.id, userColor: users.color, + ownerName: users.displayName, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) @@ -126,6 +127,7 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { row.calendarId, row.calendarName ?? '', row.userId, + row.ownerName ?? null, color, row.isShared, ) diff --git a/apps/api/tests/broker/expand.test.ts b/apps/api/tests/broker/expand.test.ts index 0267e6e..6d5d130 100644 --- a/apps/api/tests/broker/expand.test.ts +++ b/apps/api/tests/broker/expand.test.ts @@ -46,6 +46,7 @@ describe('expandOccurrences', () => { 1, // calendarId 'My Calendar', // calendarName 1, // ownerUserId + 'Alice', // ownerName '#4A90D9', // color false, // isShared ) @@ -58,6 +59,8 @@ describe('expandOccurrences', () => { // after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket. for (const occ of occurrences) { expect(occ.allDay).toBe(false) + // ownerName must be threaded through to every occurrence + expect(occ.ownerName).toBe('Alice') // start must be IANA-annotated: '2026-03-01T10:00:00-05:00[America/New_York]' expect(occ.start).toMatch(/T10:00:00/) // Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from() @@ -96,6 +99,7 @@ describe('expandOccurrences', () => { 1, 'My Calendar', 1, + null, // ownerName '#4A90D9', false, ) @@ -127,6 +131,7 @@ describe('expandOccurrences', () => { 1, 'My Calendar', 1, + null, // ownerName '#4A90D9', false, ) @@ -156,6 +161,7 @@ describe('expandOccurrences', () => { 1, 'My Calendar', 1, + null, // ownerName '#4A90D9', false, ) @@ -196,6 +202,7 @@ describe('expandOccurrences', () => { 1, 'My Calendar', 1, + null, // ownerName '#4A90D9', false, ) diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index 3fd3bfc..88b039c 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -70,19 +70,22 @@ describe('GET /api/events', () => { expect(res.status).toBe(400) }) - it('returns occurrences with color and isShared fields for valid window', async () => { + it('returns occurrences with color, isShared, and ownerName fields for valid window', async () => { const { app } = await import('../../src/index.js') const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01') expect(res.status).toBe(200) - const body = await res.json() as { occurrences: Array<{ color: string; isShared: boolean }> } + const body = await res.json() as { occurrences: Array<{ color: string; isShared: boolean; ownerName: string | null }> } expect(body).toHaveProperty('occurrences') expect(Array.isArray(body.occurrences)).toBe(true) - // Each occurrence must carry color + isShared (may be empty array if DB is mocked empty) + // Each occurrence must carry color + isShared + ownerName (may be empty array if DB is mocked empty) for (const occ of body.occurrences) { expect(occ).toHaveProperty('color') expect(typeof occ.color).toBe('string') expect(occ).toHaveProperty('isShared') expect(typeof occ.isShared).toBe('boolean') + expect(occ).toHaveProperty('ownerName') + // ownerName is string | null — both are valid + expect(occ.ownerName === null || typeof occ.ownerName === 'string').toBe(true) } }) @@ -100,6 +103,7 @@ describe('GET /api/events', () => { isShared: false, userId: 1, userColor: '#4A90D9', + ownerName: 'Alice', }, ] @@ -107,7 +111,7 @@ describe('GET /api/events', () => { const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01') expect(res.status).toBe(200) - const body = await res.json() as { occurrences: Array<{ uid: string; start: string; allDay: boolean }> } + const body = await res.json() as { occurrences: Array<{ uid: string; start: string; allDay: boolean; ownerName: string | null }> } expect(body.occurrences.length).toBeGreaterThan(0) // All returned occurrences must be inside [2026-06-01, 2026-07-01) @@ -118,6 +122,8 @@ describe('GET /api/events', () => { const startDate = occ.start.slice(0, 10) // 'YYYY-MM-DD' expect(startDate >= '2026-06-01').toBe(true) expect(startDate < '2026-07-01').toBe(true) + // ownerName from the mock row must be threaded through + expect(occ.ownerName).toBe('Alice') } }) @@ -135,6 +141,7 @@ describe('GET /api/events', () => { isShared: false, userId: 1, userColor: '#50C878', + ownerName: 'Bob', }, ] diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index f5f7a10..cd330f4 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -57,6 +57,12 @@ export interface CalendarOccurrence { calendarId: number // DB calendar-row id — do NOT use for SX calendarId routing calendarName: string ownerUserId: number // DB user id — the correct Schedule-X routing key + /** + * Display name of the calendar owner (users.displayName from the API). + * Null when the user has not configured a display name. + * Popover renders: isShared ? 'Family' : (ownerName ?? calendarName) + */ + ownerName: string | null color: string // hex from users.color or shared-family constant isShared: boolean // true → 'shared' slot; false → String(ownerUserId) slot title: string diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx index e9197ef..34e66d9 100644 --- a/apps/pwa/src/components/EventDetailPopover.test.tsx +++ b/apps/pwa/src/components/EventDetailPopover.test.tsx @@ -39,6 +39,7 @@ const TIMED_OCCURRENCE: CalendarOccurrence = { calendarId: 1, calendarName: 'My Calendar', ownerUserId: 1, + ownerName: 'Alice', color: '#4A90D9', isShared: false, title: 'Team Standup', @@ -64,6 +65,7 @@ const ALLDAY_OCCURRENCE: CalendarOccurrence = { calendarId: 2, calendarName: 'Shared Calendar', ownerUserId: 1, + ownerName: 'Alice', color: '#F25C7A', isShared: true, title: 'Birthday Party', @@ -124,8 +126,25 @@ describe('EventDetailPopover', () => { expect(screen.getByText(/Daily team sync meeting/)).toBeDefined() }) - it('renders calendar name in footer', () => { + it('renders owner name in footer for personal events', () => { renderPopover(TIMED_OCCURRENCE) + // TIMED_OCCURRENCE is personal (isShared:false) with ownerName:'Alice' + expect(screen.getByText(/Alice/)).toBeDefined() + }) + + it('renders "Family" in footer for shared calendar events', () => { + renderPopover(ALLDAY_OCCURRENCE) + // ALLDAY_OCCURRENCE has isShared:true — footer must show 'Family' + expect(screen.getByText('Family')).toBeDefined() + }) + + it('renders calendarName in footer when ownerName is null', () => { + const noOwnerName: CalendarOccurrence = { + ...TIMED_OCCURRENCE, + id: 'no-owner-uid::2026-06-15T10:00:00', + ownerName: null, + } + renderPopover(noOwnerName) expect(screen.getByText(/My Calendar/)).toBeDefined() }) diff --git a/apps/pwa/src/components/EventDetailPopover.tsx b/apps/pwa/src/components/EventDetailPopover.tsx index 05f0c2d..8b64fb6 100644 --- a/apps/pwa/src/components/EventDetailPopover.tsx +++ b/apps/pwa/src/components/EventDetailPopover.tsx @@ -369,8 +369,12 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) { }} aria-hidden="true" /> - {/* Plain text — XSS guard */} - {occurrence.calendarName} + {/* Plain text — XSS guard (T-02e-01). + Show 'Family' for shared-calendar events; owner display name for personal + events; fall back to calendarName when ownerName is null. */} + {occurrence.isShared + ? 'Family' + : (occurrence.ownerName ?? occurrence.calendarName)} {/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}