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.
This commit is contained in:
@@ -45,6 +45,13 @@ export interface CalendarOccurrence {
|
|||||||
* Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars.
|
* Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars.
|
||||||
*/
|
*/
|
||||||
ownerUserId: number
|
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 */
|
/** Hex color: users.color for personal calendars, '#F25C7A' for shared-family */
|
||||||
color: string
|
color: string
|
||||||
/** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */
|
/** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */
|
||||||
@@ -67,6 +74,8 @@ export interface OccurrenceMeta {
|
|||||||
calendarId: number
|
calendarId: number
|
||||||
calendarName: string
|
calendarName: string
|
||||||
ownerUserId: number
|
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) */
|
/** Hex color: pre-computed by the route (users.color or shared-family constant) */
|
||||||
color: string
|
color: string
|
||||||
isShared: boolean
|
isShared: boolean
|
||||||
@@ -162,6 +171,7 @@ export function expandOccurrences(
|
|||||||
calendarId: number,
|
calendarId: number,
|
||||||
calendarName: string,
|
calendarName: string,
|
||||||
ownerUserId: number,
|
ownerUserId: number,
|
||||||
|
ownerName: string | null,
|
||||||
color: string,
|
color: string,
|
||||||
isShared: boolean,
|
isShared: boolean,
|
||||||
): CalendarOccurrence[] {
|
): CalendarOccurrence[] {
|
||||||
@@ -229,6 +239,7 @@ export function expandOccurrences(
|
|||||||
calendarId,
|
calendarId,
|
||||||
calendarName,
|
calendarName,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
|
ownerName,
|
||||||
color,
|
color,
|
||||||
isShared,
|
isShared,
|
||||||
title: event.summary ?? '',
|
title: event.summary ?? '',
|
||||||
@@ -274,6 +285,7 @@ export function expandOccurrences(
|
|||||||
calendarId,
|
calendarId,
|
||||||
calendarName,
|
calendarName,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
|
ownerName,
|
||||||
color,
|
color,
|
||||||
isShared,
|
isShared,
|
||||||
title: event.summary ?? '',
|
title: event.summary ?? '',
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
|||||||
isShared: calendars.isShared,
|
isShared: calendars.isShared,
|
||||||
userId: users.id,
|
userId: users.id,
|
||||||
userColor: users.color,
|
userColor: users.color,
|
||||||
|
ownerName: users.displayName,
|
||||||
})
|
})
|
||||||
.from(calendarEvents)
|
.from(calendarEvents)
|
||||||
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||||
@@ -126,6 +127,7 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
|||||||
row.calendarId,
|
row.calendarId,
|
||||||
row.calendarName ?? '',
|
row.calendarName ?? '',
|
||||||
row.userId,
|
row.userId,
|
||||||
|
row.ownerName ?? null,
|
||||||
color,
|
color,
|
||||||
row.isShared,
|
row.isShared,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe('expandOccurrences', () => {
|
|||||||
1, // calendarId
|
1, // calendarId
|
||||||
'My Calendar', // calendarName
|
'My Calendar', // calendarName
|
||||||
1, // ownerUserId
|
1, // ownerUserId
|
||||||
|
'Alice', // ownerName
|
||||||
'#4A90D9', // color
|
'#4A90D9', // color
|
||||||
false, // isShared
|
false, // isShared
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,8 @@ describe('expandOccurrences', () => {
|
|||||||
// after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket.
|
// after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket.
|
||||||
for (const occ of occurrences) {
|
for (const occ of occurrences) {
|
||||||
expect(occ.allDay).toBe(false)
|
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]'
|
// start must be IANA-annotated: '2026-03-01T10:00:00-05:00[America/New_York]'
|
||||||
expect(occ.start).toMatch(/T10:00:00/)
|
expect(occ.start).toMatch(/T10:00:00/)
|
||||||
// Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from()
|
// Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from()
|
||||||
@@ -96,6 +99,7 @@ describe('expandOccurrences', () => {
|
|||||||
1,
|
1,
|
||||||
'My Calendar',
|
'My Calendar',
|
||||||
1,
|
1,
|
||||||
|
null, // ownerName
|
||||||
'#4A90D9',
|
'#4A90D9',
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -127,6 +131,7 @@ describe('expandOccurrences', () => {
|
|||||||
1,
|
1,
|
||||||
'My Calendar',
|
'My Calendar',
|
||||||
1,
|
1,
|
||||||
|
null, // ownerName
|
||||||
'#4A90D9',
|
'#4A90D9',
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -156,6 +161,7 @@ describe('expandOccurrences', () => {
|
|||||||
1,
|
1,
|
||||||
'My Calendar',
|
'My Calendar',
|
||||||
1,
|
1,
|
||||||
|
null, // ownerName
|
||||||
'#4A90D9',
|
'#4A90D9',
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -196,6 +202,7 @@ describe('expandOccurrences', () => {
|
|||||||
1,
|
1,
|
||||||
'My Calendar',
|
'My Calendar',
|
||||||
1,
|
1,
|
||||||
|
null, // ownerName
|
||||||
'#4A90D9',
|
'#4A90D9',
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,19 +70,22 @@ describe('GET /api/events', () => {
|
|||||||
expect(res.status).toBe(400)
|
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 { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01')
|
const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01')
|
||||||
expect(res.status).toBe(200)
|
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(body).toHaveProperty('occurrences')
|
||||||
expect(Array.isArray(body.occurrences)).toBe(true)
|
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) {
|
for (const occ of body.occurrences) {
|
||||||
expect(occ).toHaveProperty('color')
|
expect(occ).toHaveProperty('color')
|
||||||
expect(typeof occ.color).toBe('string')
|
expect(typeof occ.color).toBe('string')
|
||||||
expect(occ).toHaveProperty('isShared')
|
expect(occ).toHaveProperty('isShared')
|
||||||
expect(typeof occ.isShared).toBe('boolean')
|
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,
|
isShared: false,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
userColor: '#4A90D9',
|
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')
|
const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01')
|
||||||
expect(res.status).toBe(200)
|
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)
|
expect(body.occurrences.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
// All returned occurrences must be inside [2026-06-01, 2026-07-01)
|
// 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'
|
const startDate = occ.start.slice(0, 10) // 'YYYY-MM-DD'
|
||||||
expect(startDate >= '2026-06-01').toBe(true)
|
expect(startDate >= '2026-06-01').toBe(true)
|
||||||
expect(startDate < '2026-07-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,
|
isShared: false,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
userColor: '#50C878',
|
userColor: '#50C878',
|
||||||
|
ownerName: 'Bob',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export interface CalendarOccurrence {
|
|||||||
calendarId: number // DB calendar-row id — do NOT use for SX calendarId routing
|
calendarId: number // DB calendar-row id — do NOT use for SX calendarId routing
|
||||||
calendarName: string
|
calendarName: string
|
||||||
ownerUserId: number // DB user id — the correct Schedule-X routing key
|
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
|
color: string // hex from users.color or shared-family constant
|
||||||
isShared: boolean // true → 'shared' slot; false → String(ownerUserId) slot
|
isShared: boolean // true → 'shared' slot; false → String(ownerUserId) slot
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const TIMED_OCCURRENCE: CalendarOccurrence = {
|
|||||||
calendarId: 1,
|
calendarId: 1,
|
||||||
calendarName: 'My Calendar',
|
calendarName: 'My Calendar',
|
||||||
ownerUserId: 1,
|
ownerUserId: 1,
|
||||||
|
ownerName: 'Alice',
|
||||||
color: '#4A90D9',
|
color: '#4A90D9',
|
||||||
isShared: false,
|
isShared: false,
|
||||||
title: 'Team Standup',
|
title: 'Team Standup',
|
||||||
@@ -64,6 +65,7 @@ const ALLDAY_OCCURRENCE: CalendarOccurrence = {
|
|||||||
calendarId: 2,
|
calendarId: 2,
|
||||||
calendarName: 'Shared Calendar',
|
calendarName: 'Shared Calendar',
|
||||||
ownerUserId: 1,
|
ownerUserId: 1,
|
||||||
|
ownerName: 'Alice',
|
||||||
color: '#F25C7A',
|
color: '#F25C7A',
|
||||||
isShared: true,
|
isShared: true,
|
||||||
title: 'Birthday Party',
|
title: 'Birthday Party',
|
||||||
@@ -124,8 +126,25 @@ describe('EventDetailPopover', () => {
|
|||||||
expect(screen.getByText(/Daily team sync meeting/)).toBeDefined()
|
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)
|
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()
|
expect(screen.getByText(/My Calendar/)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -369,8 +369,12 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
|
|||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{/* Plain text — XSS guard */}
|
{/* Plain text — XSS guard (T-02e-01).
|
||||||
{occurrence.calendarName}
|
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)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
|
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
|
||||||
|
|||||||
Reference in New Issue
Block a user