fix: stop the mail list hiding dividers between some rows

This commit is contained in:
Linus Rath
2026-09-30 00:21:21 +02:00
parent a24d6626b5
commit 62e07608fd
5 changed files with 180 additions and 5 deletions
@@ -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>): 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(
<CalendarMonthView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="month:2026-09-09"
// Monday 28 Sep to Sunday 4 Oct.
rangeStart={new Date(2026, 8, 28)}
rangeEnd={new Date(2026, 9, 4)}
selectedDate={new Date(2026, 8, 30)}
events={events}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
/>,
);
}
function dropOn(dayIndex: number, event: CalendarEvent) {
const cell = document.querySelectorAll<HTMLElement>('[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);
});
});
+3 -1
View File
@@ -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;
+8 -2
View File
@@ -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)`,
}}
>
<ThreadListItem
@@ -100,8 +100,9 @@ describe('updateEvent on an expanded occurrence', () => {
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<CalendarEvent> = {}) => 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();
+19 -1
View File
@@ -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<CalendarEvent>,
): Partial<CalendarEvent> {
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<CalendarStore>()(
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<string, boolean> = {};
for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {