From 388b1bd0e36f97540fd841d90b2a02e1cd386c5e Mon Sep 17 00:00:00 2001 From: wmcparla <183443443+wmcparla@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:14:03 -0500 Subject: [PATCH] feat: let overlapping calendar events lie over each other where no text is covered #149 Timed events that overlap used to share the day evenly: three in a cluster got a third each for their whole length, which in the week grid of a phone leaves room for no title at all. The columns are now only where an event starts from. It spreads over the columns beside it that are free while it runs, and over events that start far enough from it: 30 minutes, or 45 when the earlier one shows its start time. The later event is drawn on top and begins below the earlier one's title and time, set in a little so the edge of the one underneath still shows. Events that start closer together stay side by side as before, and no event gets narrower than it was. A block is never drawn under 20px tall, so a short event counts for the 25 minutes it takes up on screen, not only for its length. --- src/components/calendar/EventBlock.tsx | 3 - .../calendar/TimeGridScrollView.tsx | 8 +- src/components/calendar/WeekView.tsx | 9 +- src/lib/__tests__/calendar-utils.test.ts | 133 ++++++++++++++++++ src/lib/calendar-utils.ts | 87 +++++++++++- 5 files changed, 222 insertions(+), 18 deletions(-) diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx index b1422085e..fd1b128c4 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 7c3f2a05a..037d4d52a 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 2d47f51de..9df658803 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 088a86266..9bdd56d93 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 5afdedeb5..d87097c86 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); }