* refactor: apply biome formatting to packages/features (batch 1 - small subdirs) Format small subdirectories in packages/features: di, flags, holidays, oauth, settings, users, assignment-reason, selectedCalendar, hashedLink, host, form, form-builder, availability, data-table, pbac, schedules, troubleshooter, eventtypes, calendar-subscription, and root-level files. Also includes straggler apps/web BookEventForm.tsx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: apply biome formatting to packages/features (batch 2 - medium subdirs) Format medium subdirectories in packages/features: auth, credentials, calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone, tasker, and webhooks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: apply biome formatting to packages/features (batch 3 - bookings + insights) Format bookings and insights subdirectories in packages/features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: apply biome formatting to packages/features (batch 4 - ee) Format packages/features/ee subdirectory covering billing, workflows, organizations, teams, managed-event-types, round-robin, dsync, integration-attribute-sync, and payments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1) Format booking-audit di, actions, common, dto, repository, and types subdirectories in packages/features/booking-audit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2) Format booking-audit service subdirectory in packages/features/booking-audit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
5.7 KiB
TypeScript
216 lines
5.7 KiB
TypeScript
import dayjs from "@calcom/dayjs";
|
|
|
|
import type { CalendarEvent } from "../types/events";
|
|
|
|
export interface OverlapLayoutConfig {
|
|
baseZIndex?: number;
|
|
safetyMarginPercent?: number;
|
|
minWidthPercent?: number;
|
|
curveExponent?: number;
|
|
}
|
|
|
|
export interface EventLayout {
|
|
event: CalendarEvent;
|
|
leftOffsetPercent: number;
|
|
widthPercent: number;
|
|
baseZIndex: number;
|
|
groupIndex: number;
|
|
indexInGroup: number;
|
|
}
|
|
|
|
const DEFAULT_CONFIG: Required<OverlapLayoutConfig> = {
|
|
baseZIndex: 60,
|
|
safetyMarginPercent: 0.5,
|
|
minWidthPercent: 25,
|
|
curveExponent: 1.3,
|
|
};
|
|
|
|
/**
|
|
* Calculates variable widths for each event in a cascade based on position
|
|
* Leftmost (longest) events get more width, rightmost (shortest) get less width
|
|
* Uses anchor points for 2-4 events and smooth easing curve for 5+ events
|
|
*
|
|
* @param groupSize - Number of overlapping events in the group
|
|
* @param minWidthPercent - Minimum width to maintain readability (default 25%)
|
|
* @param curveExponent - Easing curve exponent for width distribution (default 1.3)
|
|
* @returns Array of width percentages, one for each position in the cascade
|
|
*/
|
|
function calculateVariableWidths(
|
|
groupSize: number,
|
|
minWidthPercent: number,
|
|
curveExponent: number
|
|
): number[] {
|
|
if (groupSize <= 1) {
|
|
return [100]; // Single event gets full width (100%)
|
|
}
|
|
|
|
// Define anchor points for first and last widths based on group size
|
|
let wFirst: number;
|
|
let wLast: number;
|
|
|
|
if (groupSize === 2) {
|
|
wFirst = 80;
|
|
wLast = 50;
|
|
} else if (groupSize === 3) {
|
|
wFirst = 55;
|
|
wLast = 33;
|
|
} else if (groupSize === 4) {
|
|
wFirst = 40;
|
|
wLast = 25;
|
|
} else {
|
|
wFirst = Math.max(30, 40 - 3 * (groupSize - 4));
|
|
wLast = minWidthPercent;
|
|
}
|
|
|
|
const widths: number[] = [];
|
|
for (let i = 0; i < groupSize; i++) {
|
|
const t = groupSize > 1 ? i / (groupSize - 1) : 0;
|
|
const easedT = Math.pow(1 - t, curveExponent);
|
|
const width = wLast + (wFirst - wLast) * easedT;
|
|
widths.push(Math.max(minWidthPercent, width));
|
|
}
|
|
|
|
return widths;
|
|
}
|
|
|
|
/**
|
|
* Rounds a number to 3 decimal places using standard rounding
|
|
*/
|
|
function round3(value: number): number {
|
|
return Number(value.toFixed(3));
|
|
}
|
|
|
|
/**
|
|
* Floors a number to 3 decimal places (always rounds down)
|
|
*/
|
|
function floor3(value: number): number {
|
|
return Math.floor(value * 1000) / 1000;
|
|
}
|
|
|
|
/**
|
|
* Sorts events by start time (ascending), then by end time (descending for longer events first)
|
|
*/
|
|
export function sortEvents(events: CalendarEvent[]): CalendarEvent[] {
|
|
return [...events].sort((a, b) => {
|
|
const startA = dayjs(a.start);
|
|
const startB = dayjs(b.start);
|
|
const startDiff = startA.diff(startB);
|
|
|
|
if (startDiff !== 0) {
|
|
return startDiff;
|
|
}
|
|
|
|
const endA = dayjs(a.end);
|
|
const endB = dayjs(b.end);
|
|
return endB.diff(endA);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Groups overlapping events together using a sweep algorithm
|
|
* Events overlap if one starts before another ends
|
|
*/
|
|
export function buildOverlapGroups(sortedEvents: CalendarEvent[]): CalendarEvent[][] {
|
|
if (sortedEvents.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const groups: CalendarEvent[][] = [];
|
|
let currentGroup: CalendarEvent[] = [sortedEvents[0]];
|
|
let currentGroupEnd = dayjs(sortedEvents[0].end);
|
|
|
|
for (let i = 1; i < sortedEvents.length; i++) {
|
|
const event = sortedEvents[i];
|
|
const eventStart = dayjs(event.start);
|
|
const eventEnd = dayjs(event.end);
|
|
|
|
if (eventStart.isBefore(currentGroupEnd)) {
|
|
currentGroup.push(event);
|
|
if (eventEnd.isAfter(currentGroupEnd)) {
|
|
currentGroupEnd = eventEnd;
|
|
}
|
|
} else {
|
|
groups.push(currentGroup);
|
|
currentGroup = [event];
|
|
currentGroupEnd = eventEnd;
|
|
}
|
|
}
|
|
|
|
groups.push(currentGroup);
|
|
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Calculates layout information for all events including position and z-index
|
|
* Uses variable widths and spreads events across full width with last event aligned to right edge
|
|
*/
|
|
export function calculateEventLayouts(
|
|
events: CalendarEvent[],
|
|
config: OverlapLayoutConfig = {}
|
|
): EventLayout[] {
|
|
const { baseZIndex, safetyMarginPercent, minWidthPercent, curveExponent } = {
|
|
...DEFAULT_CONFIG,
|
|
...config,
|
|
};
|
|
|
|
const sortedEvents = sortEvents(events);
|
|
const groups = buildOverlapGroups(sortedEvents);
|
|
|
|
const layouts: EventLayout[] = [];
|
|
|
|
groups.forEach((group, groupIndex) => {
|
|
const groupSize = group.length;
|
|
|
|
const widths = calculateVariableWidths(groupSize, minWidthPercent, curveExponent);
|
|
const Rmax = 100 - safetyMarginPercent;
|
|
|
|
if (groupSize === 1) {
|
|
const width = floor3(Math.min(widths[0], Rmax));
|
|
layouts.push({
|
|
event: group[0],
|
|
leftOffsetPercent: 0,
|
|
widthPercent: width,
|
|
baseZIndex: baseZIndex,
|
|
groupIndex,
|
|
indexInGroup: 0,
|
|
});
|
|
} else {
|
|
const Rmin = widths[0];
|
|
|
|
group.forEach((event, indexInGroup) => {
|
|
const t = indexInGroup / (groupSize - 1);
|
|
const ri = Rmin + (Rmax - Rmin) * t;
|
|
const leftRaw = ri - widths[indexInGroup];
|
|
const left = round3(leftRaw);
|
|
|
|
const maxWidthCap = Rmax - left;
|
|
const widthCap = Math.min(widths[indexInGroup], maxWidthCap);
|
|
const width = floor3(Math.max(0, widthCap));
|
|
|
|
layouts.push({
|
|
event,
|
|
leftOffsetPercent: left,
|
|
widthPercent: width,
|
|
baseZIndex: baseZIndex + indexInGroup,
|
|
groupIndex,
|
|
indexInGroup,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
return layouts;
|
|
}
|
|
|
|
/**
|
|
* Creates a map from event ID to layout information for quick lookup
|
|
*/
|
|
export function createLayoutMap(layouts: EventLayout[]): Map<number, EventLayout> {
|
|
const map = new Map<number, EventLayout>();
|
|
layouts.forEach((layout) => {
|
|
map.set(layout.event.id, layout);
|
|
});
|
|
return map;
|
|
}
|