From 62e07608fda45a6249e6640bb42c7cba3bcb7245 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:21:21 +0200 Subject: [PATCH] fix: stop the mail list hiding dividers between some rows --- .../calendar-month-view-drop.test.tsx | 93 +++++++++++++++++++ components/calendar/calendar-month-view.tsx | 4 +- components/email/email-list.tsx | 10 +- .../calendar-store-synthetic-ids.test.ts | 58 +++++++++++- stores/calendar-store.ts | 20 +++- 5 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 components/calendar/__tests__/calendar-month-view-drop.test.tsx diff --git a/components/calendar/__tests__/calendar-month-view-drop.test.tsx b/components/calendar/__tests__/calendar-month-view-drop.test.tsx new file mode 100644 index 000000000..4112dd066 --- /dev/null +++ b/components/calendar/__tests__/calendar-month-view-drop.test.tsx @@ -0,0 +1,93 @@ +import { render, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { CalendarMonthView } from '../calendar-month-view'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { CalendarEvent, Calendar } from '@/lib/jmap/types'; + +// #1119: dropping an event on another day of the month grid moves it there +// at the same time of day. An all-day event keeps a full LocalDateTime +// start - Stalwart drops a date-only one and leaves the event without a +// start, so it vanished from the calendar. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function makeEvent(id: string, overrides: Partial): CalendarEvent { + return { + id, + '@type': 'Event', + uid: id, + title: 'Event ' + id, + calendarIds: { 'cal-1': true }, + ...overrides, + } as unknown as CalendarEvent; +} + +function renderView(events: CalendarEvent[]) { + return render( + , + ); +} + +function dropOn(dayIndex: number, event: CalendarEvent) { + const cell = document.querySelectorAll('[data-week="2026-09-28"] [role="gridcell"]')[dayIndex]; + const json = JSON.stringify({ type: 'calendar-event', eventId: event.id, originalStart: event.start }); + fireEvent.drop(cell, { + dataTransfer: { types: ['application/x-calendar-event'], getData: () => json }, + }); +} + +describe('CalendarMonthView drop', () => { + const updateEvent = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + updateEvent.mockClear(); + useAuthStore.setState({ client: {} as never }); + }); + + afterEach(() => { + cleanup(); + useCalendarStore.setState({ events: [] }); + }); + + it('moves an all-day event with a full start, not a bare date', () => { + const event = makeEvent('a', { start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true }); + useCalendarStore.setState({ events: [event], updateEvent }); + renderView([event]); + + dropOn(3, event); // Thursday 1 Oct + + expect(updateEvent).toHaveBeenCalledWith(expect.anything(), 'a', { start: '2026-10-01T00:00:00' }, undefined); + }); + + it('leaves an all-day event dropped on its own day alone', () => { + const event = makeEvent('a', { start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true }); + useCalendarStore.setState({ events: [event], updateEvent }); + renderView([event]); + + dropOn(4, event); // Friday 2 Oct + + expect(updateEvent).not.toHaveBeenCalled(); + }); + + it('keeps the time of day of a timed event', () => { + const event = makeEvent('t', { start: '2026-10-02T17:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }); + useCalendarStore.setState({ events: [event], updateEvent }); + renderView([event]); + + dropOn(3, event); + + expect(updateEvent).toHaveBeenCalledWith(expect.anything(), 't', { start: '2026-10-01T17:30:00' }, undefined); + }); +}); diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index a81ad0640..5189dbeb8 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -255,7 +255,9 @@ export function CalendarMonthView({ const isAllDay = event?.showWithoutTime; const newStart = new Date(day); newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0); - const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"); + // `start` is a LocalDateTime for all-day events too: Stalwart drops a + // date-only value and the event is left without a start (#1119). + const newStartISO = format(newStart, isAllDay ? "yyyy-MM-dd'T'00:00:00" : "yyyy-MM-dd'T'HH:mm:ss"); if (newStartISO === data.originalStart) return; const client = useAuthStore.getState().client; if (!client) return; diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index a8c88500c..2a6f8e040 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -238,6 +238,11 @@ export function EmailList({ estimateSize, overscan: 5, getItemKey, + // Keep sub-pixel row heights. The default measurer rounds them, so a + // row could start up to half a pixel inside the one above it and paint + // over that row's divider (at 125% or 150% display scaling). + measureElement: (element, entry) => + entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height, }); const LoadingSkeleton = () => ( @@ -586,10 +591,11 @@ export function EmailList({ ref={virtualizer.measureElement} style={{ position: 'absolute', - top: 0, + // `top`, not translateY: layout snaps it to device + // pixels, so the fractional offsets stay crisp. + top: virtualItem.start, left: 0, width: '100%', - transform: `translateY(${virtualItem.start}px)`, }} > { expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); // The occurrence has no override yet: its details (here the participants) go along. + // The start was read in the occurrence's zone, so that zone goes along too. expect(client.updateCalendarEvent).toHaveBeenCalledWith( - 'maaaaab', { title: 'Renamed', start: '2026-09-08T11:00:00', participants: occurrence().participants }, + 'maaaaab', { title: 'Renamed', start: '2026-09-08T11:00:00', timeZone: 'Europe/Berlin', participants: occurrence().participants }, undefined, undefined, undefined, ); const stored = useCalendarStore.getState().events[0]; @@ -202,6 +203,61 @@ describe('updateEvent on an expanded occurrence', () => { }); }); +describe('a new start for an expanded instance (#1119)', () => { + // Stalwart expands an event whose own start is UTC (every one-off event + // in a Google Calendar export) in the request's zone, so the instance + // reads 17:30 Europe/Berlin while the base event is 15:30 Etc/UTC. + const imported = (overrides: Partial = {}) => occurrence({ + id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceIdTimeZone: null, + recurrenceRules: null, recurrenceOverrides: null, participants: undefined, + start: '2026-10-02T17:30:00', timeZone: 'Europe/Berlin', utcStart: '2026-10-02T15:30:00Z', utcEnd: '2026-10-02T16:30:00Z', + ...overrides, + }); + + it('writes it to the base event with the zone it was read in', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [imported()] }); + + await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { start: '2026-10-01T17:30:00' }); + + expect(client.updateCalendarEvent).toHaveBeenCalledWith( + 'd', { start: '2026-10-01T17:30:00', timeZone: 'Europe/Berlin' }, undefined, undefined, + ); + expect(useCalendarStore.getState().events[0].utcStart).toBe('2026-10-01T15:30:00.000Z'); + }); + + it('keeps a zone the caller chose', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [imported()] }); + + await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { start: '2026-10-01T15:30:00', timeZone: 'Etc/UTC' }); + + expect(client.updateCalendarEvent).toHaveBeenCalledWith( + 'd', { start: '2026-10-01T15:30:00', timeZone: 'Etc/UTC' }, undefined, undefined, + ); + }); + + it('leaves an all-day start floating and changes without a start alone', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [imported({ start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true })] }); + + await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { start: '2026-10-01T00:00:00' }); + await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { title: 'Renamed' }); + + expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(1, 'd', { start: '2026-10-01T00:00:00' }, undefined, undefined); + expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(2, 'd', { title: 'Renamed' }, undefined, undefined); + }); + + it('leaves events without synthetic ids alone', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [imported({ id: 'd', originalId: 'd', baseEventId: undefined, timeZone: 'Etc/UTC' })] }); + + await useCalendarStore.getState().updateEvent(client, 'd', { start: '2026-10-01T15:30:00' }); + + expect(client.updateCalendarEvent).toHaveBeenCalledWith('d', { start: '2026-10-01T15:30:00' }, undefined, undefined); + }); +}); + describe('deleteEvent on an expanded occurrence', () => { it('destroys the occurrence through its synthetic id and drops it from the store', async () => { const client = fakeClient(); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 70fb7f79d..13fd76342 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -124,6 +124,24 @@ export function resolveMutationTarget( return { realId: id, isOccurrence: false }; } +/** + * A server-expanded instance reports `start` in the zone it was expanded in - + * the request's zone whenever the event's own start is UTC or floating, as in + * a Google Calendar export - not in its base event's zone. Stalwart reads a + * written `start` in the base event's zone, so a start worked out from the + * instance has to carry the instance's zone along, or the event shifts by the + * offset between the two (#1119). All-day starts stay floating. + */ +function withInstanceTimeZone( + storeEvent: CalendarEvent | undefined, + updates: Partial, +): Partial { + if (!storeEvent || !isServerRecurrenceInstance(storeEvent)) return updates; + if (updates.start === undefined || updates.timeZone !== undefined) return updates; + if (storeEvent.showWithoutTime || updates.showWithoutTime || !storeEvent.timeZone) return updates; + return { ...updates, timeZone: storeEvent.timeZone }; +} + /** True when the store shows server-expanded occurrences of base event `baseId` on the given account. */ function hasExpandedOccurrencesOf(events: CalendarEvent[], baseId: string, target: MutationTarget): boolean { return events.some(e => @@ -881,7 +899,7 @@ export const useCalendarStore = create()( updateKeys: Object.keys(updates), }); // Remap namespaced calendarIds back to original IDs - const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates }); + const cleanUpdates = sanitizeOutgoingCalendarEventData(withInstanceTimeZone(storeEvent, { ...updates })); if (cleanUpdates.calendarIds) { const remapped: Record = {}; for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {