Sets up calendar package with core infrastructure
Adds core calendar package components including: - Base services for events, resources, and settings - Calendar app and orchestrator - Build and bundling configuration - IndexedDB storage setup Prepares foundational architecture for calendar functionality
This commit is contained in:
parent
12e7594f30
commit
ceb44446f0
97 changed files with 13858 additions and 1 deletions
279
packages/calendar/src/features/event/EventLayoutEngine.ts
Normal file
279
packages/calendar/src/features/event/EventLayoutEngine.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* EventLayoutEngine - Simplified stacking/grouping algorithm
|
||||
*
|
||||
* Supports two layout modes:
|
||||
* - GRID: Events starting at same time rendered side-by-side
|
||||
* - STACKING: Overlapping events with margin-left offset (15px per level)
|
||||
*
|
||||
* No prev/next chains, single-pass greedy algorithm
|
||||
*/
|
||||
|
||||
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||
import { IGridConfig } from '../../core/IGridConfig';
|
||||
import { calculateEventPosition } from '../../utils/PositionUtils';
|
||||
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
|
||||
|
||||
/**
|
||||
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
|
||||
* This matches Scenario 8: end===start is NOT overlap
|
||||
*/
|
||||
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
|
||||
return a.start < b.end && a.end > b.start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two events are within threshold for grid grouping.
|
||||
* This includes:
|
||||
* 1. Start-to-start: Events start within threshold of each other
|
||||
* 2. End-to-start: One event starts within threshold before another ends
|
||||
*/
|
||||
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
|
||||
const thresholdMs = thresholdMinutes * 60 * 1000;
|
||||
|
||||
// Start-to-start: both events start within threshold
|
||||
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
|
||||
if (startToStartDiff <= thresholdMs) return true;
|
||||
|
||||
// End-to-start: one event starts within threshold before the other ends
|
||||
// B starts within threshold before A ends
|
||||
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
|
||||
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
|
||||
|
||||
// A starts within threshold before B ends
|
||||
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
|
||||
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all events in a group start within threshold of each other
|
||||
*/
|
||||
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
|
||||
if (events.length <= 1) return true;
|
||||
|
||||
// Find earliest and latest start times
|
||||
let earliest = events[0].start.getTime();
|
||||
let latest = events[0].start.getTime();
|
||||
|
||||
for (const event of events) {
|
||||
const time = event.start.getTime();
|
||||
if (time < earliest) earliest = time;
|
||||
if (time > latest) latest = time;
|
||||
}
|
||||
|
||||
const diffMinutes = (latest - earliest) / (1000 * 60);
|
||||
return diffMinutes <= thresholdMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find groups of overlapping events (connected by overlap chain)
|
||||
* Events are grouped if they overlap with any event in the group
|
||||
*/
|
||||
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
const used = new Set<string>();
|
||||
const groups: ICalendarEvent[][] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
if (used.has(event.id)) continue;
|
||||
|
||||
// Start a new group with this event
|
||||
const group: ICalendarEvent[] = [event];
|
||||
used.add(event.id);
|
||||
|
||||
// Expand group by finding all connected events (via overlap)
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const candidate of sorted) {
|
||||
if (used.has(candidate.id)) continue;
|
||||
|
||||
// Check if candidate overlaps with any event in group
|
||||
const connects = group.some(member => eventsOverlap(member, candidate));
|
||||
|
||||
if (connects) {
|
||||
group.push(candidate);
|
||||
used.add(candidate.id);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find grid candidates within a group - events connected via threshold chain
|
||||
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
|
||||
*/
|
||||
function findGridCandidates(
|
||||
events: ICalendarEvent[],
|
||||
thresholdMinutes: number
|
||||
): ICalendarEvent[][] {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
const used = new Set<string>();
|
||||
const groups: ICalendarEvent[][] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
if (used.has(event.id)) continue;
|
||||
|
||||
const group: ICalendarEvent[] = [event];
|
||||
used.add(event.id);
|
||||
|
||||
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const candidate of sorted) {
|
||||
if (used.has(candidate.id)) continue;
|
||||
|
||||
const connects = group.some(member =>
|
||||
eventsWithinThreshold(member, candidate, thresholdMinutes)
|
||||
);
|
||||
|
||||
if (connects) {
|
||||
group.push(candidate);
|
||||
used.add(candidate.id);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stack levels for overlapping events using greedy algorithm
|
||||
* For each event: level = max(overlapping already-processed events) + 1
|
||||
*/
|
||||
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
|
||||
const levels = new Map<string, number>();
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
for (const event of sorted) {
|
||||
let maxOverlappingLevel = -1;
|
||||
|
||||
// Find max level among overlapping events already processed
|
||||
for (const [id, level] of levels) {
|
||||
const other = events.find(e => e.id === id);
|
||||
if (other && eventsOverlap(event, other)) {
|
||||
maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
|
||||
}
|
||||
}
|
||||
|
||||
levels.set(event.id, maxOverlappingLevel + 1);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate events to columns for GRID layout using greedy algorithm
|
||||
* Non-overlapping events can share a column to minimize total columns
|
||||
*/
|
||||
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
const columns: ICalendarEvent[][] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
// Find first column where event doesn't overlap with existing events
|
||||
let placed = false;
|
||||
for (const column of columns) {
|
||||
const canFit = !column.some(e => eventsOverlap(event, e));
|
||||
if (canFit) {
|
||||
column.push(event);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No suitable column found, create new one
|
||||
if (!placed) {
|
||||
columns.push([event]);
|
||||
}
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: Calculate complete layout for a column's events
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Find overlap groups (events connected by overlap chain)
|
||||
* 2. For each overlap group, find grid candidates (events within threshold chain)
|
||||
* 3. If all events in overlap group form a single grid candidate → GRID mode
|
||||
* 4. Otherwise → STACKING mode with calculated levels
|
||||
*/
|
||||
export function calculateColumnLayout(
|
||||
events: ICalendarEvent[],
|
||||
config: IGridConfig
|
||||
): IColumnLayout {
|
||||
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
|
||||
|
||||
const result: IColumnLayout = {
|
||||
grids: [],
|
||||
stacked: []
|
||||
};
|
||||
|
||||
if (events.length === 0) return result;
|
||||
|
||||
// Find all overlapping event groups
|
||||
const overlapGroups = findOverlapGroups(events);
|
||||
|
||||
for (const overlapGroup of overlapGroups) {
|
||||
if (overlapGroup.length === 1) {
|
||||
// Single event - no grouping needed
|
||||
result.stacked.push({
|
||||
event: overlapGroup[0],
|
||||
stackLevel: 0
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Within this overlap group, find grid candidates (threshold-connected subgroups)
|
||||
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
|
||||
|
||||
// Check if the ENTIRE overlap group forms a single grid candidate
|
||||
// This happens when all events are connected via threshold chain
|
||||
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
|
||||
g.length > max.length ? g : max, gridSubgroups[0]);
|
||||
|
||||
if (largestGridCandidate.length === overlapGroup.length) {
|
||||
// All events in overlap group are connected via threshold chain → GRID mode
|
||||
const columns = allocateColumns(overlapGroup);
|
||||
const earliest = overlapGroup.reduce((min, e) =>
|
||||
e.start < min.start ? e : min, overlapGroup[0]);
|
||||
const position = calculateEventPosition(earliest.start, earliest.end, config);
|
||||
|
||||
result.grids.push({
|
||||
events: overlapGroup,
|
||||
columns,
|
||||
stackLevel: 0,
|
||||
position: { top: position.top }
|
||||
});
|
||||
} else {
|
||||
// Not all events connected via threshold → STACKING mode
|
||||
const levels = calculateStackLevels(overlapGroup);
|
||||
for (const event of overlapGroup) {
|
||||
result.stacked.push({
|
||||
event,
|
||||
stackLevel: levels.get(event.id) ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue