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:
Lucas Berger
2026-06-05 15:14:43 -04:00
parent fc758e8ea6
commit 194f6a82a8
7 changed files with 64 additions and 7 deletions
+12
View File
@@ -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 ?? '',
+2
View File
@@ -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,
)
+7
View File
@@ -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,
)
+11 -4
View File
@@ -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',
},
]