From 1543db49d1395978fc086e18d9e2f9db104d1d64 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:43:27 +0200 Subject: [PATCH] fix: match webmail event display logic --- src/components/calendar/AgendaView.tsx | 58 +++++++++++--------- src/components/calendar/EventCard.tsx | 17 ++++-- src/components/calendar/EventDetailSheet.tsx | 13 +++-- src/lib/__tests__/calendar-utils.test.ts | 29 ++++++++++ src/lib/calendar-utils.ts | 24 +++++++- src/screens/CalendarScreen.tsx | 24 ++++++-- src/stores/__tests__/calendar-store.test.ts | 28 ++++++++++ src/stores/calendar-store.ts | 19 ++++++- 8 files changed, 168 insertions(+), 44 deletions(-) diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index 2a1e4715..c7e1d460 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -1,15 +1,16 @@ import React from 'react'; import { SectionList, StyleSheet, Text, View } from 'react-native'; -import { format, isSameDay, isToday, isTomorrow } from 'date-fns'; +import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns'; import { CalendarDays } from 'lucide-react-native'; import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { buildEventDayIndex, + dayKey, eventsOnDayFromIndex, - getEventStartDate, type EventDayIndex, + type TimeFormat, } from '../../lib/calendar-utils'; import { EventCard } from './EventCard'; @@ -19,6 +20,7 @@ interface AgendaViewProps { events: CalendarEvent[]; calendars: Calendar[]; eventsByDay?: EventDayIndex; + timeFormat?: TimeFormat; onSelectEvent?: (event: CalendarEvent) => void; } @@ -40,6 +42,7 @@ export function AgendaView({ events, calendars, eventsByDay, + timeFormat, onSelectEvent, }: AgendaViewProps) { const c = useColors(); @@ -50,32 +53,35 @@ export function AgendaView({ ); const sections = React.useMemo(() => { - const result: DaySection[] = []; - const start = new Date(fromDate); - start.setHours(0, 0, 0, 0); + // Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for + // every day that actually has events, always include a "Today" anchor, and + // render them sorted ascending. Driving the list off the events (rather than + // a fixed day-counter) guarantees every upcoming loaded event appears - not + // just the ones inside a brittle N-day window from the anchor. + const anchor = startOfDay(fromDate); + const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive) - for (let i = 0; i < daysAhead; i++) { - const day = new Date(start); - day.setDate(start.getDate() + i); - const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => { - // All-day events first within a day, then by start time, then title - // (stable tie-break) - mirrors the comparator the webmail uses for - // packing week segments and keeps multi-event days predictable. - if (a.showWithoutTime !== b.showWithoutTime) { - return a.showWithoutTime ? -1 : 1; - } - const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime(); - if (diff !== 0) return diff; - return (a.title || '').localeCompare(b.title || ''); - }); - if (dayEvents.length === 0 && !isToday(day)) continue; - result.push({ + const days = new Map(); + for (const key of index.keys()) { + const day = parseISO(key); // index keys are local yyyy-MM-dd + if (isNaN(day.getTime())) continue; + if (day < anchor || day >= horizonEnd) continue; + days.set(key, day); + } + // Always anchor "Today" so the view has a stable starting point even when + // the current day has no events (webmail injects the same anchor). + const today = startOfDay(new Date()); + if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today); + + return [...days.values()] + .sort((a, b) => a.getTime() - b.getTime()) + .map((day) => ({ title: formatDayHeader(day), date: day, - data: dayEvents, - }); - } - return result; + // Buckets are pre-sorted by buildEventDayIndex (all-day first, then by + // start, then title), so no extra sort is needed here. + data: eventsOnDayFromIndex(index, day), + })); }, [fromDate, daysAhead, index]); if (sections.length === 0) { @@ -107,7 +113,7 @@ export function AgendaView({ }} renderItem={({ item }) => ( - + )} renderSectionFooter={({ section }) => diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx index 0cfd488d..56b02bfe 100644 --- a/src/components/calendar/EventCard.tsx +++ b/src/components/calendar/EventCard.tsx @@ -5,11 +5,17 @@ import { format } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { eventTimeRange, getEventColor } from '../../lib/calendar-utils'; +import { + eventTimeRange, + getEventColor, + timePattern, + type TimeFormat, +} from '../../lib/calendar-utils'; interface EventCardProps { event: CalendarEvent; calendars: Calendar[]; + timeFormat?: TimeFormat; onPress?: (event: CalendarEvent) => void; onLongPress?: (event: CalendarEvent) => void; } @@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number { return event.participants ? Object.keys(event.participants).length : 0; } -function formatTimeRange(event: CalendarEvent): string { +function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string { const { start, end, allDay } = eventTimeRange(event); if (allDay) return 'All day'; - return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`; + const fmt = timePattern(timeFormat); + return `${format(start, fmt)} – ${format(end, fmt)}`; } -export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) { +export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const color = getEventColor(event, calendars); - const time = formatTimeRange(event); + const time = formatTimeRange(event, timeFormat); const count = participantCount(event); return ( diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index ee2c2503..71c9ebe1 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -36,6 +36,8 @@ import { eventTimeRange, getEventColor, getPrimaryCalendarId, + timePattern, + type TimeFormat, } from '../../lib/calendar-utils'; import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts'; import { @@ -50,6 +52,7 @@ type RsvpStatus = 'accepted' | 'declined' | 'tentative'; interface EventDetailSheetProps { event: CalendarEvent | null; calendars: Calendar[]; + timeFormat?: TimeFormat; onClose: () => void; onEdit?: (event: CalendarEvent) => void; onDelete?: (event: CalendarEvent) => void; @@ -57,7 +60,7 @@ interface EventDetailSheetProps { onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise; } -function formatRange(event: CalendarEvent): string { +function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string { const { start, end, allDay } = eventTimeRange(event); if (allDay) { if ( @@ -69,14 +72,15 @@ function formatRange(event: CalendarEvent): string { } return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`; } + const t = timePattern(timeFormat); const sameDay = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth() && start.getDate() === end.getDate(); if (sameDay) { - return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`; + return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`; } - return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`; + return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`; } function recurrenceLabel(event: CalendarEvent): string | null { @@ -100,6 +104,7 @@ function recurrenceLabel(event: CalendarEvent): string | null { export function EventDetailSheet({ event, calendars, + timeFormat, onClose, onEdit, onDelete, @@ -156,7 +161,7 @@ export function EventDetailSheet({ const color = getEventColor(event, calendars); const calendarId = getPrimaryCalendarId(event); const calendar = calendars.find((c) => c.id === calendarId); - const range = formatRange(event); + const range = formatRange(event, timeFormat); const recurrence = recurrenceLabel(event); const reminders = alertsToReminders(event.alerts); const participants = event.participants ? Object.values(event.participants) : []; diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts index 81e4754a..56ffb506 100644 --- a/src/lib/__tests__/calendar-utils.test.ts +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -7,6 +7,9 @@ import { normalizeAllDayDuration, buildAllDayDuration, getCalendarColor, + getEventStartDate, + getEventEndDate, + timePattern, layoutOverlappingEvents, CALENDAR_COLOR_PALETTE, } from '../calendar-utils'; @@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => { }); }); +describe('getEventStartDate / getEventEndDate malformed utc fallback', () => { + // Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the + // floating `start` + duration instead of producing an Invalid Date, which + // would silently drop the event from the day index and every view. + it('falls back to start when utcStart is unparseable', () => { + const d = getEventStartDate(ev({ utcStart: 'garbage' })); + expect(isNaN(d.getTime())).toBe(false); + expect(d.getHours()).toBe(9); + }); + + it('falls back to start + duration when utcEnd is unparseable', () => { + const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' })); + expect(isNaN(end.getTime())).toBe(false); + const start = getEventStartDate(ev({})); + expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000); + }); +}); + +describe('timePattern', () => { + it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => { + expect(timePattern('12h')).toBe('h:mm a'); + expect(timePattern('24h')).toBe('HH:mm'); + expect(timePattern(undefined)).toBe('HH:mm'); + }); +}); + describe('eventTimeRange', () => { it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => { const range = eventTimeRange(ev({ diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index 2d4d9b90..5c17bc7c 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date { export function getEventStartDate( event: Pick, ): Date { - const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start; - return parseISO(source); + // Prefer utcStart for timed events but fall back to start if utcStart is + // missing or unparseable — a malformed utcStart used to surface as an + // Invalid Date that silently dropped the event from every view (#316). + if (!event.showWithoutTime && event.utcStart) { + const utc = parseISO(event.utcStart); + if (!isNaN(utc.getTime())) return utc; + } + return parseISO(event.start); } export function getEventEndDate(event: CalendarEvent): Date { if (!event.showWithoutTime && event.utcEnd) { - return parseISO(event.utcEnd); + const utc = parseISO(event.utcEnd); + if (!isNaN(utc.getTime())) return utc; } const start = getEventStartDate(event); if (!event.duration) return start; @@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date { return subMilliseconds(end, 1); } +// ─── Time-of-day formatting ────────────────────────────── +// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail +// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components +// thread the `calendarTimeFormat` setting through so the calendar renders +// times in the same format the webmail does. +export type TimeFormat = '12h' | '24h'; + +export function timePattern(timeFormat: TimeFormat | undefined): string { + return timeFormat === '12h' ? 'h:mm a' : 'HH:mm'; +} + export interface EventTimeRange { start: Date; end: Date; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 3dd16a8d..b8426f6c 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -58,6 +58,7 @@ import { pickUnusedCalendarColor, sharedCalendarColorKey, type EventDayIndex, + type TimeFormat, } from '../lib/calendar-utils'; import { buildReplyTo } from '../lib/calendar-invitation'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; @@ -291,25 +292,34 @@ export default function CalendarScreen() { [], ); + // A recurring series member is a master (has recurrenceRules) or an expanded + // occurrence (has recurrenceId) — those get the this/all scope dialog. Note: + // we must NOT key off originalId here, since shared/group events now carry it + // for id-namespacing even when they aren't recurring. + const isRecurringSeriesMember = React.useCallback( + (event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId, + [], + ); + const handleEditFromDetail = React.useCallback((event: CalendarEvent) => { if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } setDetailEvent(null); - if (event.recurrenceRules?.length || event.originalId) { + if (isRecurringSeriesMember(event)) { setPendingAction({ kind: 'edit', event }); } else { openEditDirect(event); } - }, [openEditDirect, isReadOnlyEvent]); + }, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]); const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => { if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } setDetailEvent(null); - if (event.recurrenceRules?.length || event.originalId) { + if (isRecurringSeriesMember(event)) { setPendingAction({ kind: 'delete', event }); } else { void deleteEvent(event.id); } - }, [deleteEvent, isReadOnlyEvent]); + }, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]); const handleScopeSelect = React.useCallback( async (scope: RecurrenceEditScope) => { @@ -459,6 +469,7 @@ export default function CalendarScreen() { events={events} eventsByDay={eventsByDay} calendars={calendars} + timeFormat={calendarTimeFormat} onSelectEvent={setDetailEvent} /> )} @@ -475,6 +486,7 @@ export default function CalendarScreen() { date={selectedDate} eventsByDay={eventsByDay} calendars={calendars} + timeFormat={calendarTimeFormat} onSelectEvent={setDetailEvent} refreshing={refreshing} onRefresh={onRefresh} @@ -486,6 +498,7 @@ export default function CalendarScreen() { setDetailEvent(null)} onEdit={handleEditFromDetail} onDelete={handleDeleteFromDetail} @@ -570,6 +583,7 @@ function DayEventList({ date, eventsByDay, calendars, + timeFormat, onSelectEvent, refreshing, onRefresh, @@ -577,6 +591,7 @@ function DayEventList({ date: Date; eventsByDay: EventDayIndex; calendars: Calendar[]; + timeFormat?: TimeFormat; onSelectEvent?: (event: CalendarEvent) => void; refreshing: boolean; onRefresh: () => void; @@ -613,6 +628,7 @@ function DayEventList({ key={event.id} event={event} calendars={calendars} + timeFormat={timeFormat} onPress={onSelectEvent} /> ))} diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index d8612538..50d594f9 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -101,6 +101,34 @@ describe('calendar-store', () => { expect(useCalendarStore.getState().events).toEqual([]); expect(mockGetEvents).not.toHaveBeenCalled(); }); + + it('namespaces shared/group event ids by owning account and keeps the real id in originalId', async () => { + // A shared calendar lives in another account; its event id ("ev1") can + // collide with one of the user's own events, so the store must namespace + // it. Mirrors webmail's mapServerEventToStoreEvent. + useCalendarStore.setState({ + calendars: [ + { id: 'cal-1', name: 'Personal' } as any, + { id: 'shared-cal', name: 'Team', accountId: 'acc-2', isShared: true } as any, + ], + }); + mockQueryEvents.mockImplementation(async (_ids: string[], _a: string, _b: string, accountId?: string) => + accountId === 'acc-2' ? ['ev1'] : [], + ); + mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) => + accountId === 'acc-2' + ? [{ id: 'ev1', title: 'Standup', start: '2026-03-15T10:00:00', calendarIds: { 'shared-cal': true } }] + : [], + ); + + await useCalendarStore.getState().fetchEvents(['cal-1', 'shared-cal'], '2026-03-01', '2026-03-31'); + + const stored = useCalendarStore.getState().events; + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe('acc-2:ev1'); + expect(stored[0].originalId).toBe('ev1'); + expect(stored[0].accountId).toBe('acc-2'); + }); }); describe('ensureRange', () => { diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 0216d30b..25f2c45b 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -152,7 +152,17 @@ export const useCalendarStore = create()( const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; if (eventIds.length === 0) continue; const fetched = (await fetchEvents(eventIds, accountId)) ?? []; - raw.push(...(accountId ? fetched.map((e) => ({ ...e, accountId })) : fetched)); + // Shared/group accounts: JMAP object ids are only unique *within* an + // account, so two accounts can return events with the same id. + // Namespace shared ids with the owning accountId (stashing the real id + // in originalId for mutations) so they don't collide with the user's + // own events in the store / React keys. Mirrors webmail's + // mapServerEventToStoreEvent. + raw.push( + ...(accountId + ? fetched.map((e) => ({ ...e, accountId, id: `${accountId}:${e.id}`, originalId: e.id })) + : fetched), + ); } catch (err) { // A failing shared account must not hide the user's own events. if (!accountId) throw err; @@ -233,7 +243,12 @@ export const useCalendarStore = create()( hasSchedulingParticipants(event) ? true : undefined, accountId, ); - set({ events: [...get().events, accountId ? { ...created, accountId } : created] }); + // Namespace shared/group event ids the same way fetchEvents does so the + // optimistic insert doesn't collide with the user's own events. + const storeCreated = accountId + ? { ...created, accountId, id: `${accountId}:${created.id}`, originalId: created.id } + : created; + set({ events: [...get().events, storeCreated] }); return created; },