Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions src/components/calendar/EventBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions src/components/calendar/TimeGridScrollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
getEventColor,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
TIME_LINE_MIN_MINUTES,
type CalendarWeekSegment,
type EventDayIndex,
type TimedEventLayout,
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<TimedEventBlock
key={event.id}
Expand All @@ -662,7 +662,7 @@ const DayColumn = React.memo(function DayColumn({
continuesAfter={continuesAfter}
accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })}
onPress={() => onSelectEvent?.(event)}
style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }}
style={{ top, height, left: `${left * 100}%`, width: `${width * 100}%` }}
/>
);
})}
Expand Down
9 changes: 4 additions & 5 deletions src/components/calendar/WeekView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<TimedEventBlock
key={event.id}
Expand All @@ -300,7 +299,7 @@ function WeekViewInner({
continuesAfter={continuesAfter}
accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })}
onPress={() => onSelectEvent?.(event)}
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }}
style={{ top, height, left: `${left * 100}%`, width: `${width * 100}%` }}
/>
);
},
Expand Down
133 changes: 133 additions & 0 deletions src/lib/__tests__/calendar-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 81 additions & 6 deletions src/lib/calendar-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimedEventLayout, 'startMinutes' | 'endMinutes'>;

/** 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,
Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down
Loading