calendarEventLayout.test.ts6.6 KBView on GitHub /**
* Tests for the Google-Calendar-style overlap layout: collision grouping, lane packing,
* rightward expansion, and the CSS geometry those produce.
*/
import {
calculateEventLayout,
getEventLaneGeometry,
EVENT_LANE_BLEED,
} from '@/modules/calendar/calendarUtils';
import type { CalendarEvent } from '@/modules/calendar/types/calendar-types';
/** Build an event on 2026-07-30 from "HH:MM" strings. */
const ev = (id: string, start: string, end: string): CalendarEvent =>
({
id,
start: { dateTime: `2026-07-30T${start}:00.000Z` },
end: { dateTime: `2026-07-30T${end}:00.000Z` },
}) as CalendarEvent;
const layoutOf = (events: CalendarEvent[]) => {
const layout = calculateEventLayout(events);
return (id: string) => {
const info = layout.get(`${id}__default`);
if (!info) throw new Error(`no layout for ${id}`);
return info;
};
};
describe('calculateEventLayout', () => {
it('gives a lone event the whole column', () => {
const at = layoutOf([ev('a', '09:00', '10:00')]);
expect(at('a')).toEqual({ columns: 1, column: 0, span: 1 });
});
it('keeps non-overlapping events in independent groups, each full width', () => {
const at = layoutOf([ev('a', '09:00', '10:00'), ev('b', '11:00', '12:00')]);
expect(at('a')).toEqual({ columns: 1, column: 0, span: 1 });
expect(at('b')).toEqual({ columns: 1, column: 0, span: 1 });
});
it('splits two overlapping events into adjacent lanes', () => {
const at = layoutOf([ev('a', '09:00', '10:00'), ev('b', '09:30', '10:30')]);
expect(at('a')).toMatchObject({ columns: 2, column: 0, span: 1 });
expect(at('b')).toMatchObject({ columns: 2, column: 1, span: 1 });
});
it('puts the longer event in lane 0 when two events start together', () => {
const at = layoutOf([ev('short', '09:00', '09:30'), ev('long', '09:00', '11:00')]);
expect(at('long').column).toBe(0);
expect(at('short').column).toBe(1);
});
it('reuses a lane once its occupant has ended', () => {
// c starts after a ends, so it drops back into lane 0 rather than opening a third lane.
const at = layoutOf([
ev('a', '09:00', '10:00'),
ev('b', '09:30', '11:30'),
ev('c', '10:00', '11:00'),
]);
expect(at('b').columns).toBe(2);
expect(at('a').column).toBe(0);
expect(at('b').column).toBe(1);
expect(at('c').column).toBe(0);
});
it('groups events linked only transitively', () => {
// a and b never touch; c bridges them, so all three must share one lane count.
const at = layoutOf([
ev('a', '09:00', '10:00'),
ev('b', '10:30', '11:30'),
ev('c', '09:30', '11:00'),
]);
expect(at('a').columns).toBe(2);
expect(at('b').columns).toBe(2);
expect(at('c').columns).toBe(2);
expect(at('c').column).toBe(1);
});
it('expands an event across lanes that hold nothing beside it', () => {
// Three events collide at 09:00, forcing 3 lanes. The 14:00 event shares the group only
// through a long spanning event, and should still stretch across all three lanes.
const at = layoutOf([
ev('spanner', '09:00', '15:00'),
ev('m1', '09:00', '10:00'),
ev('m2', '09:00', '10:00'),
ev('afternoon', '14:00', '15:00'),
]);
expect(at('m2').columns).toBe(3);
expect(at('afternoon').column).toBe(1);
expect(at('afternoon').span).toBe(2);
// The spanner is boxed in for its whole duration, so it never widens.
expect(at('spanner')).toMatchObject({ column: 0, span: 1 });
});
it('treats a zero-length event as occupying a slot', () => {
// A 0-minute event still paints a ~14px chip, so a later event must not claim its lane.
const at = layoutOf([ev('instant', '09:00', '09:00'), ev('after', '09:05', '10:00')]);
expect(at('instant').columns).toBe(2);
expect(at('after').column).toBe(1);
});
it('skips events without usable times', () => {
const layout = calculateEventLayout([
{ id: 'no-times' } as CalendarEvent,
ev('ok', '09:00', '10:00'),
]);
expect(layout.has('no-times__default')).toBe(false);
expect(layout.has('ok__default')).toBe(true);
});
it('keys events by calendar so the same event on two calendars gets two lanes', () => {
const base = ev('dupe', '09:00', '10:00');
const layout = calculateEventLayout([
{ ...base, calendarId: 'work' } as CalendarEvent,
{ ...base, calendarId: 'personal' } as CalendarEvent,
]);
expect(layout.get('dupe__work')).toMatchObject({ columns: 2, column: 0 });
expect(layout.get('dupe__personal')).toMatchObject({ columns: 2, column: 1 });
});
});
describe('getEventLaneGeometry', () => {
it('fills the column when there is nothing to stack against', () => {
expect(getEventLaneGeometry({ columns: 1, column: 0, span: 1 })).toEqual({
leftPercent: 0,
widthPercent: 100,
depth: 0,
});
});
it('bleeds into the lane on the right so the chip stays readable', () => {
const left = getEventLaneGeometry({ columns: 3, column: 0, span: 1 });
expect(left.leftPercent).toBeCloseTo(0);
// One lane plus the bleed — wider than the 33.3% a hard grid would allow.
expect(left.widthPercent).toBeCloseTo((1 + EVENT_LANE_BLEED) * (100 / 3));
expect(left.widthPercent).toBeGreaterThan(100 / 3);
});
it('stops the rightmost chip at the column edge', () => {
const right = getEventLaneGeometry({ columns: 3, column: 2, span: 1 });
expect(right.leftPercent).toBeCloseTo(200 / 3);
expect(right.leftPercent + right.widthPercent).toBeCloseTo(100);
});
it('never lets a chip overflow the column', () => {
for (const columns of [1, 2, 3, 4, 5, 8]) {
for (let column = 0; column < columns; column++) {
for (let span = 1; span <= columns - column; span++) {
const { leftPercent, widthPercent } = getEventLaneGeometry({ columns, column, span });
expect(widthPercent).toBeGreaterThan(0);
expect(leftPercent + widthPercent).toBeLessThanOrEqual(100.0001);
}
}
}
});
it('paints lanes further right on top', () => {
expect(getEventLaneGeometry({ columns: 3, column: 0, span: 1 }).depth).toBe(0);
expect(getEventLaneGeometry({ columns: 3, column: 2, span: 1 }).depth).toBe(2);
});
it('leaves every chip in a stack a visible left strip', () => {
// Each chip's left edge sits a full lane clear of the chip behind it, so the title strip
// is never covered by the chip painted on top of it.
const columns = 4;
const lefts = Array.from(
{ length: columns },
(_, column) => getEventLaneGeometry({ columns, column, span: 1 }).leftPercent,
);
for (let i = 1; i < lefts.length; i++) {
expect(lefts[i] - lefts[i - 1]).toBeCloseTo(100 / columns);
}
});
});