diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx index b1422085..fd1b128c 100644 --- a/src/components/calendar/EventBlock.tsx +++ b/src/components/calendar/EventBlock.tsx @@ -12,9 +12,6 @@ import { TINTED_BAR_WIDTH, type EventBlockColors } from '../../lib/event-colors' /** Corner radius of event blocks and bars. */ export const EVENT_RADIUS = 2; -/** Timed blocks this long or longer show their start time under the title. */ -export const TIME_LINE_MIN_MINUTES = 45; - // Corners on a side the event continues past (the day before or after, the // week before or after) stay square. function corners( diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 7c3f2a05..037d4d52 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -30,6 +30,7 @@ import { getEventColor, isTimedEventFullDayOnDate, layoutOverlappingEvents, + TIME_LINE_MIN_MINUTES, type CalendarWeekSegment, type EventDayIndex, type TimedEventLayout, @@ -49,7 +50,7 @@ import { windowDays, type TimeGridMode, } from '../../lib/calendar-time-grid'; -import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock'; +import { AllDayEventBar, TimedEventBlock } from './EventBlock'; import { AllDayToggle } from './AllDayToggle'; const HOUR_HEIGHT = 48; @@ -640,10 +641,9 @@ const DayColumn = React.memo(function DayColumn({ onPress={onTapAt ? handlePress : undefined} onLongPress={onLongPressAt ? handleLongPress : undefined} > - {layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => { + {layouts.map(({ event, left, width, startMinutes, endMinutes, continuesBefore, continuesAfter }) => { const top = (startMinutes / 60) * HOUR_HEIGHT; const height = Math.max(20, ((endMinutes - startMinutes) / 60) * HOUR_HEIGHT - 1); - const widthPct = 100 / totalColumns; return ( onSelectEvent?.(event)} - style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }} + style={{ top, height, left: `${left * 100}%`, width: `${width * 100}%` }} /> ); })} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 2d47f51d..9df65880 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -23,12 +23,13 @@ import { getEventColor, isTimedEventFullDayOnDate, layoutOverlappingEvents, + TIME_LINE_MIN_MINUTES, type CalendarWeekSegment, type EventDayIndex, } from '../../lib/calendar-utils'; import { isInactiveEvent } from '../../lib/calendar-participants'; import { eventBlockColors } from '../../lib/event-colors'; -import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock'; +import { AllDayEventBar, TimedEventBlock } from './EventBlock'; import { AllDayToggle } from './AllDayToggle'; import { packAllDaySegments, shownAllDayRows } from '../../lib/calendar-time-grid'; @@ -274,14 +275,12 @@ function WeekViewInner({ ))} {layouted.map( - ({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => { + ({ event, left, width, startMinutes, endMinutes, continuesBefore, continuesAfter }) => { const top = (startMinutes / 60) * HOUR_HEIGHT; const height = Math.max( 20, ((endMinutes - startMinutes) / 60) * HOUR_HEIGHT - 1, ); - const widthPct = 100 / totalColumns; - const leftPct = column * widthPct; return ( onSelectEvent?.(event)} - style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }} + style={{ top, height, left: `${left * 100}%`, width: `${width * 100}%` }} /> ); }, diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts index 088a8626..9bdd56d9 100644 --- a/src/lib/__tests__/calendar-utils.test.ts +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -282,6 +282,139 @@ describe('layoutOverlappingEvents', () => { }); }); +// #149: events spread over free columns and over events they do not hide the +// text of, instead of every event of a cluster getting an equal share. +describe('layoutOverlappingEvents widths', () => { + const day = new Date(2026, 3, 20); + // Floating times, so the test reads the same in any time zone. + const minutes = (hhmm: string) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3)); + const at = (id: string, from: string, to: string) => ev({ + id, + start: `2026-04-20T${from}:00`, + duration: `PT${minutes(to) - minutes(from)}M`, + }); + // id -> [left, right] in hundredths of the day column. + const frames = (events: CalendarEvent[]) => Object.fromEntries( + layoutOverlappingEvents(events, day).map((l) => [ + l.event.id, + [Math.round(l.left * 100), Math.round((l.left + l.width) * 100)], + ]), + ); + + it('gives a lone event the whole column', () => { + expect(frames([at('a', '09:00', '10:00')])).toEqual({ a: [0, 100] }); + }); + + it('keeps events that start together side by side', () => { + expect(frames([at('a', '09:00', '10:00'), at('b', '09:00', '10:00')])) + .toEqual({ a: [0, 50], b: [50, 100] }); + }); + + it('keeps an event beside one whose start time it would cover', () => { + // 30 minutes clears a title, not the time under it. + expect(frames([at('a', '09:00', '10:00'), at('b', '09:30', '10:30')])) + .toEqual({ a: [0, 50], b: [50, 100] }); + }); + + it('draws a later event over a long one, set in from its edge', () => { + expect(frames([at('a', '09:00', '12:00'), at('b', '10:00', '11:00')])) + .toEqual({ a: [0, 100], b: [13, 100] }); + }); + + it('lets a short event be covered once its title is clear', () => { + expect(frames([at('a', '09:00', '09:40'), at('b', '09:30', '10:30')])) + .toEqual({ a: [0, 100], b: [13, 100] }); + }); + + it('sets each further event in from the ones under it', () => { + expect(frames([ + at('a', '09:00', '13:00'), + at('b', '10:00', '12:00'), + at('c', '11:00', '12:00'), + ])).toEqual({ a: [0, 100], b: [8, 100], c: [17, 100] }); + }); + + it('spreads an event into a column that is free while it runs', () => { + // c cannot be drawn over a (it would cover its time), but b has ended. + expect(frames([ + at('a', '09:00', '11:00'), + at('b', '09:00', '09:30'), + at('c', '09:30', '10:30'), + ])).toEqual({ a: [0, 50], b: [50, 100], c: [50, 100] }); + expect(frames([ + at('a', '09:00', '10:00'), + at('b', '09:00', '10:00'), + at('c', '09:00', '12:00'), + at('d', '10:00', '11:00'), + ])).toMatchObject({ d: [8, 100] }); + }); + + it('counts the room a short block is drawn in, not only its length', () => { + // A block is never drawn under 25 minutes tall. + expect(frames([at('a', '09:00', '10:00'), at('z', '09:00', '09:00')])) + .toEqual({ a: [0, 50], z: [50, 100] }); + // y ends at 9:00 but is drawn until 9:20: z stays out of its column. + expect(frames([ + at('x', '08:50', '11:00'), + at('w', '08:50', '09:00'), + at('y', '08:55', '09:00'), + at('z', '09:00', '10:00'), + ])).toMatchObject({ y: [67, 100], z: [33, 67] }); + }); + + it('lays out the example from the issue', () => { + const got = frames([ + at('a', '04:00', '08:00'), + at('b', '04:00', '07:45'), + at('c', '04:00', '05:00'), + at('d', '04:00', '05:00'), + at('e', '04:45', '05:45'), + at('f', '05:00', '06:00'), + at('g', '05:45', '06:45'), + at('h', '06:45', '07:45'), + ]); + // Four start together and stay side by side; the short ones end at 5. + expect(got).toMatchObject({ a: [0, 20], b: [20, 40], c: [40, 60], d: [60, 100] }); + // 4:45 lies over the 4-5 event beside it; 5-6 would cover its title, so they stay apart. + expect(got.e).toEqual([65, 100]); + expect(got.f).toEqual([25, 60]); + // The later ones lie over the long two, each set in from what it covers. + expect(got.g).toEqual([30, 100]); + expect(got.h).toEqual([25, 100]); + }); + + it('never makes an event narrower than its column, and returns later starts last', () => { + const events = [ + at('a', '08:00', '12:00'), at('b', '08:15', '09:00'), at('c', '08:50', '10:10'), + at('d', '09:00', '09:20'), at('e', '09:10', '11:00'), at('f', '09:40', '10:00'), + at('g', '10:00', '12:30'), at('h', '10:05', '10:35'), at('i', '11:15', '12:00'), + ]; + const layout = layoutOverlappingEvents(events, day); + expect(layout).toHaveLength(events.length); + layout.forEach((l, i) => { + expect(l.left).toBeLessThanOrEqual(l.column / l.totalColumns + 1e-9); + expect(l.left + l.width).toBeGreaterThanOrEqual((l.column + 1) / l.totalColumns - 1e-9); + expect(l.left + l.width).toBeLessThanOrEqual(1 + 1e-9); + if (i > 0) expect(l.startMinutes).toBeGreaterThanOrEqual(layout[i - 1].startMinutes); + }); + // Where two blocks share ground, the one on top starts below the other's text. + for (const under of layout) { + for (const over of layout) { + if (under === over || over.startMinutes < under.startMinutes) continue; + // One after the other in a column, as before: a short block's + // minimum height can reach into the next. + if (under.column === over.column) continue; + const shareTime = over.startMinutes < Math.max(under.endMinutes, under.startMinutes + 25); + const shareWidth = over.left < under.left + under.width - 1e-9 + && under.left < over.left + over.width - 1e-9; + if (!shareTime || !shareWidth) continue; + const clear = under.endMinutes - under.startMinutes >= 45 ? 45 : 30; + expect(over.startMinutes - under.startMinutes).toBeGreaterThanOrEqual(clear); + } + } + }); +}); + // Stalwart 1.0 accepts a calendar colour only as #rgb, #rrggbb or a CSS name // (groupware/src/calendar/color.rs). The edit sheet sent the colour with // every rename, so a calendar another client coloured with Apple's 8-digit diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index 5afdedeb..d87097c8 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -432,16 +432,94 @@ export function buildTimedFullDayWeekSegments( // the same `totalColumns`; across clusters, events get to use the full width // they actually need. The previous global packing forced an event with no // neighbors at 17:00 to share columns with a 9am cluster - visible squish. +// +// The columns are then only a starting point (#149): an event spreads over +// the columns beside it that are free while it runs, and over events that +// start far enough from it that the later one, drawn on top, begins below +// the earlier one's text. `left` and `width` are what the grids draw; the +// result is in drawing order, later starts last. export interface TimedEventLayout { event: CalendarEvent; column: number; totalColumns: number; + /** Start edge and width as fractions of the day column. */ + left: number; + width: number; startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean; } +/** Timed blocks this long or longer show their start time under the title. */ +export const TIME_LINE_MIN_MINUTES = 45; + +// How long after an event starts another may be drawn over it: past its +// title (17px of a 48px hour), or past the start time under it (30px). +const TITLE_CLEAR_MINUTES = 30; +const TIME_LINE_CLEAR_MINUTES = 45; + +// The grids draw no block shorter than 20px, 25 minutes of a 48px hour, so +// a short event takes up room past its end. +const MIN_DRAWN_MINUTES = 25; + +/** How far, in columns, an event on top is set in from the one under it. */ +const OVERLAP_INDENT = 0.25; + +type TimedSpan = Pick; + +/** Whether the later of two events starts below the earlier one's text. */ +function startsClearOf(a: TimedSpan, b: TimedSpan): boolean { + const [first, second] = a.startMinutes <= b.startMinutes ? [a, b] : [b, a]; + const clear = first.endMinutes - first.startMinutes >= TIME_LINE_MIN_MINUTES + ? TIME_LINE_CLEAR_MINUTES + : TITLE_CLEAR_MINUTES; + return second.startMinutes - first.startMinutes >= clear; +} + +/** + * Sets `left` and `width` for one cluster, given in start order with its + * columns assigned. No event ends up narrower than its column. + */ +function spreadCluster(items: TimedEventLayout[], total: number): void { + // Columns each event covers. Events not reached yet cover their own. + const from = items.map((item) => item.column); + const to = items.map((item) => item.column); + const left = items.map((item) => item.column); + + const drawnEnd = items.map((item) => Math.max(item.endMinutes, item.startMinutes + MIN_DRAWN_MINUTES)); + + items.forEach((item, i) => { + const concurrent = items + .map((_, j) => j) + .filter((j) => j !== i + && items[j].startMinutes < drawnEnd[i] + && item.startMinutes < drawnEnd[j]); + const covers = (j: number, col: number) => col >= from[j] && col <= to[j]; + const canTake = (col: number, allow: (j: number) => boolean = () => true) => + concurrent.every((j) => !covers(j, col) || (startsClearOf(item, items[j]) && allow(j))); + + while (from[i] > 0 && canTake(from[i] - 1)) from[i]--; + + // Set in from every earlier event underneath, so its edge still shows. + left[i] = from[i]; + for (const j of concurrent) { + if (j < i && to[j] >= from[i] && from[j] <= item.column) { + left[i] = Math.max(left[i], left[j] + OVERLAP_INDENT); + } + } + left[i] = Math.min(left[i], item.column); + + // To the right, only over earlier events whose edge would still show. + const showsEdge = (j: number) => j > i || left[j] + OVERLAP_INDENT <= left[i]; + while (to[i] < total - 1 && canTake(to[i] + 1, showsEdge)) to[i]++; + + item.totalColumns = total; + item.left = left[i] / total; + item.width = (to[i] + 1 - left[i]) / total; + }); +} + export function layoutOverlappingEvents( events: CalendarEvent[], day: Date, @@ -463,10 +541,7 @@ export function layoutOverlappingEvents( let clusterMaxEnd = 0; const flushCluster = () => { - const total = columns.length; - for (let i = clusterStart; i < result.length; i++) { - result[i].totalColumns = total; - } + spreadCluster(result.slice(clusterStart), columns.length); }; for (const item of sorted) { @@ -481,14 +556,14 @@ export function layoutOverlappingEvents( for (let col = 0; col < columns.length; col++) { if (columns[col].every((e) => e.end <= item.startMinutes)) { columns[col].push({ end: item.endMinutes }); - result.push({ ...item, column: col, totalColumns: 0 }); + result.push({ ...item, column: col, totalColumns: 0, left: 0, width: 0 }); placed = true; break; } } if (!placed) { columns.push([{ end: item.endMinutes }]); - result.push({ ...item, column: columns.length - 1, totalColumns: 0 }); + result.push({ ...item, column: columns.length - 1, totalColumns: 0, left: 0, width: 0 }); } clusterMaxEnd = Math.max(clusterMaxEnd, item.endMinutes); }