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);
    }
  });
});