feat: build the occurrence index

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-29 21:52:09 -04:00
parent 51bf195664
commit cd270e9e99
10 changed files with 344 additions and 167 deletions
+23 -26
View File
@@ -27,19 +27,19 @@
<v-list-item-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title>
</v-list-item>
</template>
<template v-for="(dayEvents, date) in groupedEvents" :key="date">
<template v-for="(dayOccurrences, date) in groupedEvents" :key="date">
<v-list-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
<v-list-item
v-for="entity in dayEvents"
:key="entity.identifier"
@click="$emit('event-click', entity)"
v-for="occurrence in dayOccurrences"
:key="occurrence.key"
@click="$emit('event-click', occurrence.entity)"
>
<template #prepend>
<v-icon :color="getEventColor(entity)">mdi-circle</v-icon>
<v-icon :color="getEventColor(occurrence)">mdi-circle</v-icon>
</template>
<v-list-item-title>{{ entity.properties?.label || 'Untitled' }}</v-list-item-title>
<v-list-item-title>{{ occurrence.entity.properties?.label || 'Untitled' }}</v-list-item-title>
<v-list-item-subtitle>
{{ getEventProperties(entity).timeless ? 'All day' : `${formatEventDateTime(getEventProperties(entity).startsOn)} - ${formatEventDateTime(getEventProperties(entity).endsOn)}` }}
{{ occurrence.timeless ? 'All day' : `${formatOccurrenceTime(occurrence.startMs)} - ${formatOccurrenceTime(occurrence.endMs)}` }}
</v-list-item-subtitle>
</v-list-item>
</template>
@@ -54,12 +54,14 @@ import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { EventObject } from '@ChronoManager/models/event';
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
import { occurrencesForRange } from '@/utils/occurrenceIndex';
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
type CalendarEntity = EntityObject;
type CalendarCollection = CollectionObject;
const props = defineProps<{
events: CalendarEntity[];
occurrenceIndex: CalendarOccurrenceIndex;
calendars: CalendarCollection[];
currentDate: Date;
initialSpan?: AgendaViewSpan;
@@ -126,32 +128,27 @@ const dateRangeLabel = computed(() => {
});
const groupedEvents = computed(() => {
const grouped: Record<string, CalendarEntity[]> = {};
const grouped: Record<string, CalendarOccurrence[]> = {};
const { start, end } = dateRange.value;
const filtered = props.events.filter((e): e is CalendarEntity => {
const startsOn = getEventProperties(e).startsOn;
if (typeof startsOn !== 'string') return false;
const eventStart = new Date(startsOn);
return eventStart >= start && eventStart < end;
const occurrences = occurrencesForRange(props.occurrenceIndex, {
startMs: start.getTime(),
endMs: end.getTime(),
});
const sorted = filtered.sort((a, b) =>
new Date(getEventProperties(a).startsOn || 0).getTime() - new Date(getEventProperties(b).startsOn || 0).getTime()
);
sorted.forEach(entity => {
const dateKey = new Date(getEventProperties(entity).startsOn || 0).toDateString();
occurrences.forEach(occurrence => {
const dateKey = new Date(occurrence.startMs).toDateString();
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
grouped[dateKey].push(entity);
grouped[dateKey].push(occurrence);
});
return grouped;
});
function getEventColor(entity: CalendarEntity): string {
function getEventColor(occurrence: CalendarOccurrence): string {
const entity = occurrence.entity;
const event = getEventProperties(entity);
if (event.color) return event.color;
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
@@ -166,8 +163,8 @@ function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
function formatEventDateTime(value: string | null): string {
return value ? formatTime(new Date(value)) : '';
function formatOccurrenceTime(timestamp: number): string {
return formatTime(new Date(timestamp));
}
function formatAgendaDate(dateString: string): string {
+5 -4
View File
@@ -9,7 +9,7 @@
<MonthView
v-if="view === 'month'"
:current-date="currentDate"
:events="events"
:occurrence-index="occurrenceIndex"
:calendars="calendars"
@event-click="$emit('event-click', $event)"
@date-click="$emit('date-click', $event)"
@@ -20,7 +20,7 @@
<DaysView
v-else-if="view === 'days'"
:current-date="currentDate"
:events="events"
:occurrence-index="occurrenceIndex"
:calendars="calendars"
:initial-span="initialDaysSpan"
@event-click="$emit('event-click', $event)"
@@ -32,7 +32,7 @@
/>
<AgendaView
v-else-if="view === 'agenda'"
:events="events"
:occurrence-index="occurrenceIndex"
:calendars="calendars"
:current-date="currentDate"
:initial-span="initialAgendaViewSpan"
@@ -53,12 +53,13 @@ import { spanToDays, type AgendaViewSpan, type DaysViewSpan } from '@/types/span
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import type { VisibleDateRange } from '@/types';
import type { CalendarOccurrenceIndex } from '@/types/occurrence';
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/dateRanges';
const props = defineProps<{
view: 'days' | 'month' | 'agenda';
currentDate: Date;
events: EntityObject[];
occurrenceIndex: CalendarOccurrenceIndex;
calendars: CollectionObject[];
initialDaysSpan?: DaysViewSpan;
initialAgendaViewSpan?: AgendaViewSpan;
+30 -30
View File
@@ -45,18 +45,18 @@
<div class="all-day-events-overlay">
<div
v-for="segment in allDaySegments.segments"
:key="segment.entity.identifier"
:key="segment.occurrence.key"
class="all-day-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="getAllDayEventStyle(segment)"
@click="emit('event-click', segment.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: segment.entity })"
@click="emit('event-click', segment.occurrence.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
@mouseleave="emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">{{ segment.entity.properties?.label || 'Untitled' }}</span>
<span v-if="segment.isStart" class="event-label">{{ segment.occurrence.entity.properties?.label || 'Untitled' }}</span>
</div>
</div>
</div>
@@ -73,22 +73,22 @@
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
<div class="day-events" @click="handleDayClick($event, day)">
<div
v-for="entity in getTimedEvents(day)"
:key="entity.identifier"
v-for="occurrence in getTimedEvents(day)"
:key="occurrence.key"
class="day-event"
:style="getEventStyle(entity)"
@click.stop="emit('event-click', entity)"
@mouseenter="emit('event-hover', { event: $event, entity })"
:style="getEventStyle(occurrence)"
@click.stop="emit('event-click', occurrence.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: occurrence.entity })"
@mouseleave="emit('event-hover-end')"
>
<template v-if="daysCount <= 3">
<div class="event-time">
{{ formatEventDateTime(getEventProperties(entity).startsOn) }} - {{ formatEventDateTime(getEventProperties(entity).endsOn) }}
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
</div>
<div class="event-title">{{ getEventProperties(entity).label || 'Untitled' }}</div>
<div class="event-title">{{ getEventProperties(occurrence.entity).label || 'Untitled' }}</div>
</template>
<template v-else>
<div class="event-title-compact">{{ getEventProperties(entity).label || 'Untitled' }}</div>
<div class="event-title-compact">{{ getEventProperties(occurrence.entity).label || 'Untitled' }}</div>
</template>
</div>
</div>
@@ -104,7 +104,6 @@ import { ref, computed, watch } from 'vue';
import {
startOfDay,
getMultiDaySegments,
getTimedEventsForDate,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
import { shiftLocalDays } from '@/utils/dateRanges';
@@ -112,6 +111,8 @@ import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { EventObject } from '@ChronoManager/models/event';
import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
const ALL_DAY_EVENT_HEIGHT = 24;
@@ -120,7 +121,7 @@ type CalendarCollection = CollectionObject;
const props = defineProps<{
currentDate: Date;
events: CalendarEntity[];
occurrenceIndex: CalendarOccurrenceIndex;
calendars: CalendarCollection[];
initialSpan?: DaysViewSpan;
}>();
@@ -205,7 +206,12 @@ const allDaySegments = computed(() => {
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
};
return getMultiDaySegments(props.events, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
const occurrences = occurrencesForRange(props.occurrenceIndex, {
startMs: rangeStart.getTime(),
endMs: shiftLocalDays(rangeEnd, 1).getTime(),
});
return getMultiDaySegments(occurrences, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
});
function isToday(date: Date): boolean {
@@ -213,8 +219,8 @@ function isToday(date: Date): boolean {
return date.toDateString() === today.toDateString();
}
function getTimedEvents(date: Date): CalendarEntity[] {
return getTimedEventsForDate(props.events, date) as CalendarEntity[];
function getTimedEvents(date: Date): CalendarOccurrence[] {
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
}
function getEventProperties(entity: CalendarEntity): EventObject {
@@ -230,28 +236,22 @@ function getEventColor(entity: CalendarEntity): string {
function getAllDayEventStyle(segment: MultiDaySegment) {
return {
backgroundColor: getEventColor(segment.entity),
backgroundColor: getEventColor(segment.occurrence.entity),
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
};
}
function getEventStyle(entity: CalendarEntity) {
const event = getEventProperties(entity);
if (!event.startsOn || !event.endsOn) {
return { display: 'none' };
}
const startTime = new Date(event.startsOn);
const endTime = new Date(event.endsOn);
function getEventStyle(occurrence: CalendarOccurrence) {
const startTime = new Date(occurrence.startMs);
const start = startTime.getHours() * 60 + startTime.getMinutes();
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
const duration = occurrence.durationMs / (1000 * 60);
return {
top: `${(start / 1440) * 100}%`,
height: `${(duration / 1440) * 100}%`,
backgroundColor: getEventColor(entity),
backgroundColor: getEventColor(occurrence.entity),
};
}
@@ -265,8 +265,8 @@ function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
function formatEventDateTime(value: string | null): string {
return value ? formatTime(new Date(value)) : '';
function formatOccurrenceTime(timestamp: number): string {
return formatTime(new Date(timestamp));
}
function formatWeekDay(date: Date): string {
+33 -87
View File
@@ -2,12 +2,12 @@
import { computed, ref, onMounted, onUnmounted } from 'vue';
import {
startOfDay,
daysDiff,
isMultiDay,
getSingleDayEventsForDate,
getMultiDaySegments,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
import { shiftLocalMonths } from '@/utils/dateRanges';
import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
const EVENT_HEIGHT = 22; // Height of each event row in pixels
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
@@ -21,7 +21,7 @@ interface WeekData {
const props = defineProps<{
currentDate: Date;
events: any[];
occurrenceIndex: CalendarOccurrenceIndex;
calendars: any[];
}>();
@@ -113,7 +113,21 @@ const weeks = computed<WeekData[]>(() => {
}
// Get multiday segments for this week
const { segments, laneCount } = getMultiDaySegments(weekStart, weekEnd);
const occurrences = occurrencesForRange(props.occurrenceIndex, {
startMs: weekStart.getTime(),
endMs: new Date(
weekEnd.getFullYear(),
weekEnd.getMonth(),
weekEnd.getDate() + 1,
).getTime(),
});
const { segments, laneCount } = getMultiDaySegments(
occurrences,
weekStart,
weekEnd,
7,
date => date.getDay(),
);
weeksData.push({
startDate: weekStart,
@@ -126,77 +140,9 @@ const weeks = computed<WeekData[]>(() => {
return weeksData;
});
function getMultiDaySegments(weekStart: Date, weekEnd: Date): { segments: MultiDaySegment[]; laneCount: number } {
const segments: MultiDaySegment[] = [];
// Filter multiday events that overlap this week
const multiDayEvents = props.events.filter(entity => {
if (!entity.properties?.startsOn || !isMultiDay(entity)) return false;
const eventStart = startOfDay(new Date(entity.properties.startsOn));
const eventEnd = startOfDay(new Date(entity.properties.endsOn));
return eventStart <= weekEnd && eventEnd >= weekStart;
});
// Sort: longer events first, then by start date
multiDayEvents.sort((a, b) => {
const aStart = new Date(a.properties.startsOn);
const aEnd = new Date(a.properties.endsOn);
const bStart = new Date(b.properties.startsOn);
const bEnd = new Date(b.properties.endsOn);
const aDuration = daysDiff(aStart, aEnd);
const bDuration = daysDiff(bStart, bEnd);
if (bDuration !== aDuration) return bDuration - aDuration;
return aStart.getTime() - bStart.getTime();
});
// Lane assignment
const lanes: boolean[][] = []; // lanes[lane][dayOfWeek] = occupied
for (const entity of multiDayEvents) {
const eventStart = startOfDay(new Date(entity.properties.startsOn));
const eventEnd = startOfDay(new Date(entity.properties.endsOn));
// Clamp to this week
const segStart = eventStart < weekStart ? weekStart : eventStart;
const segEnd = eventEnd > weekEnd ? weekEnd : eventEnd;
const startCol = segStart.getDay();
const endCol = segEnd.getDay();
const span = endCol - startCol + 1;
// Find available lane
let lane = 0;
while (true) {
if (!lanes[lane]) lanes[lane] = [false, false, false, false, false, false, false];
let isFree = true;
for (let d = startCol; d <= endCol; d++) {
if (lanes[lane][d]) { isFree = false; break; }
}
if (isFree) {
for (let d = startCol; d <= endCol; d++) lanes[lane][d] = true;
break;
}
lane++;
}
segments.push({
entity,
startCol,
span,
lane,
isStart: eventStart >= weekStart,
isEnd: eventEnd <= weekEnd,
});
}
return { segments, laneCount: lanes.length };
}
// Single-day events for a specific date
function getSingleDayEvents(date: Date): any[] {
return getSingleDayEventsForDate(props.events, date);
function getSingleDayEvents(date: Date): CalendarOccurrence[] {
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.multiDay);
}
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
@@ -215,7 +161,7 @@ function isToday(date: Date): boolean {
return date.toDateString() === today.toDateString();
}
function getEventColor(entity: any): string {
function getEventColor(entity: CalendarOccurrence['entity']): string {
if (entity.properties?.color) return entity.properties.color;
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
return calendar?.properties?.color || '#1976D2';
@@ -265,15 +211,15 @@ function getEventColor(entity: any): string {
<!-- Single-day events -->
<div class="cell-events">
<div
v-for="entity in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
:key="entity.identifier"
v-for="occurrence in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
:key="occurrence.key"
class="single-day-event"
:style="{ backgroundColor: getEventColor(entity) }"
@click.stop="$emit('event-click', entity)"
@mouseenter="$emit('event-hover', { event: $event, entity })"
:style="{ backgroundColor: getEventColor(occurrence.entity) }"
@click.stop="$emit('event-click', occurrence.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: occurrence.entity })"
@mouseleave="$emit('event-hover-end')"
>
{{ entity.properties?.label || 'Untitled' }}
{{ occurrence.entity.properties?.label || 'Untitled' }}
</div>
<div
v-if="getHiddenCount(cell, week.laneCount) > 0"
@@ -290,24 +236,24 @@ function getEventColor(entity: any): string {
<div class="multiday-overlay">
<div
v-for="segment in week.multiDaySegments"
:key="`${segment.entity.identifier}-${weekIndex}`"
:key="`${segment.occurrence.key}-${weekIndex}`"
class="multiday-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="{
backgroundColor: getEventColor(segment.entity),
backgroundColor: getEventColor(segment.occurrence.entity),
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
width: `calc(${segment.span} / 7 * 100% - 8px)`,
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
}"
@click.stop="$emit('event-click', segment.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: segment.entity })"
@click.stop="$emit('event-click', segment.occurrence.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
@mouseleave="$emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">
{{ segment.entity.properties?.label || 'Untitled' }}
{{ segment.occurrence.entity.properties?.label || 'Untitled' }}
</span>
</div>
</div>
+2 -2
View File
@@ -43,9 +43,9 @@ const {
loading,
calendars,
taskLists,
filteredEvents,
filteredTasks,
collections,
occurrenceIndex,
} = storeToRefs(chronoOperationsStore);
const {
@@ -277,7 +277,7 @@ watch(() => route.fullPath, () => {
v-if="!isTaskView"
:view="calendarView"
:current-date="currentDate"
:events="filteredEvents"
:occurrence-index="occurrenceIndex"
:calendars="calendars"
:initial-days-span="daysViewSpan"
:initial-agenda-view-span="agendaViewSpan"
+7
View File
@@ -10,6 +10,7 @@ import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
import { useImportStore } from '@ChronoManager/stores/importStore'
import type { VisibleDateRange } from '@/types'
import { buildOccurrenceIndex, occurrencesForRange } from '@/utils/occurrenceIndex'
type ChronoEntityProperties = EventObject | TaskObject
@@ -44,6 +45,10 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
})
const filteredTasks = computed(() => tasks.value)
const occurrenceIndex = computed(() => buildOccurrenceIndex(filteredEvents.value))
const visibleOccurrences = computed(() => visibleRange.value
? occurrencesForRange(occurrenceIndex.value, visibleRange.value)
: [])
function setVisibleRange(range: VisibleDateRange) {
if (
@@ -176,6 +181,8 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
tasks,
filteredEvents,
filteredTasks,
occurrenceIndex,
visibleOccurrences,
setVisibleRange,
saveEntity,
deleteEntity,
+19
View File
@@ -0,0 +1,19 @@
import type { EntityObject } from '@ChronoManager/models/entity'
export interface CalendarOccurrence {
key: string
entity: EntityObject
occurrenceId: string | null
startMs: number
endMs: number
dayStartMs: number
durationMs: number
timeless: boolean
multiDay: boolean
}
export interface CalendarOccurrenceIndex {
byDay: Map<number, CalendarOccurrence[]>
byEntity: Map<string, CalendarOccurrence[]>
sorted: CalendarOccurrence[]
}
+18 -18
View File
@@ -3,8 +3,10 @@
* Shared logic for multi-day event handling across calendar views
*/
import type { CalendarOccurrence } from '@/types/occurrence'
export interface MultiDaySegment {
entity: any;
occurrence: CalendarOccurrence;
startCol: number;
span: number;
lane: number;
@@ -85,7 +87,7 @@ export function eventOnDate(entity: any, date: Date): boolean {
* Used for rendering spanning events across columns
*/
export function getMultiDaySegments(
events: any[],
occurrences: CalendarOccurrence[],
rangeStart: Date,
rangeEnd: Date,
columnCount: number,
@@ -94,30 +96,28 @@ export function getMultiDaySegments(
const segments: MultiDaySegment[] = [];
// Filter multi-day/all-day events that overlap this range
const multiDayEvents = events.filter(entity => {
if (!entity.properties?.startsOn) return false;
if (!isAllDay(entity)) return false;
return eventOverlapsRange(entity, rangeStart, rangeEnd);
const rangeStartMs = startOfDay(rangeStart).getTime();
const rangeEndExclusive = startOfDay(new Date(rangeEnd));
rangeEndExclusive.setDate(rangeEndExclusive.getDate() + 1);
const multiDayEvents = occurrences.filter(occurrence => {
return occurrence.timeless
&& occurrence.startMs < rangeEndExclusive.getTime()
&& occurrence.endMs > rangeStartMs;
});
// Sort: longer events first, then by start date
multiDayEvents.sort((a, b) => {
const aStart = new Date(a.properties.startsOn);
const aEnd = new Date(a.properties.endsOn || a.properties.startsOn);
const bStart = new Date(b.properties.startsOn);
const bEnd = new Date(b.properties.endsOn || b.properties.startsOn);
const aDuration = daysDiff(aStart, aEnd);
const bDuration = daysDiff(bStart, bEnd);
if (bDuration !== aDuration) return bDuration - aDuration;
return aStart.getTime() - bStart.getTime();
if (b.durationMs !== a.durationMs) return b.durationMs - a.durationMs;
return a.startMs - b.startMs;
});
// Lane assignment - track which days are occupied in each lane
const lanes: boolean[][] = [];
for (const entity of multiDayEvents) {
const eventStart = startOfDay(new Date(entity.properties.startsOn));
const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
for (const occurrence of multiDayEvents) {
const eventStart = startOfDay(new Date(occurrence.startMs));
const eventEnd = startOfDay(new Date(occurrence.endMs - 1));
// Clamp to visible range
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
@@ -156,7 +156,7 @@ export function getMultiDaySegments(
}
segments.push({
entity,
occurrence,
startCol,
span,
lane,
+117
View File
@@ -0,0 +1,117 @@
import type { EntityObject } from '@ChronoManager/models/entity'
import type { EventObject } from '@ChronoManager/models/event'
import type {
CalendarOccurrence,
CalendarOccurrenceIndex,
} from '../types/occurrence'
import type { VisibleDateRange } from '../types'
import { shiftLocalDays, startOfLocalDay } from './dateRanges'
const MINIMUM_DURATION_MS = 1
function eventTimestamp(value: string | null | undefined): number | null {
if (!value) return null
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) ? timestamp : null
}
function occurrenceFromEntity(entity: EntityObject): CalendarOccurrence | null {
const event = entity.properties as EventObject
const startMs = eventTimestamp(event.startsOn)
if (startMs === null) return null
const parsedEndMs = eventTimestamp(event.endsOn)
const endMs = parsedEndMs !== null && parsedEndMs > startMs
? parsedEndMs
: startMs + MINIMUM_DURATION_MS
const dayStartMs = startOfLocalDay(new Date(startMs)).getTime()
const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).getTime()
const entityKey = String(entity.identifier)
return {
key: `${entityKey}:master`,
entity,
occurrenceId: null,
startMs,
endMs,
dayStartMs,
durationMs: endMs - startMs,
timeless: event.timeless === true || lastOccupiedDayMs !== dayStartMs,
multiDay: lastOccupiedDayMs !== dayStartMs,
}
}
function appendToDayBuckets(index: CalendarOccurrenceIndex, occurrence: CalendarOccurrence) {
const lastOccupiedDayMs = startOfLocalDay(new Date(occurrence.endMs - 1)).getTime()
let day = new Date(occurrence.dayStartMs)
while (day.getTime() <= lastOccupiedDayMs) {
const dayKey = day.getTime()
const bucket = index.byDay.get(dayKey)
if (bucket) bucket.push(occurrence)
else index.byDay.set(dayKey, [occurrence])
day = startOfLocalDay(shiftLocalDays(day, 1))
}
}
function sortOccurrences(occurrences: CalendarOccurrence[]) {
occurrences.sort((left, right) => (
left.startMs - right.startMs
|| right.durationMs - left.durationMs
|| left.key.localeCompare(right.key)
))
}
export function buildOccurrenceIndex(entities: EntityObject[]): CalendarOccurrenceIndex {
const index: CalendarOccurrenceIndex = {
byDay: new Map(),
byEntity: new Map(),
sorted: [],
}
for (const entity of entities) {
const occurrence = occurrenceFromEntity(entity)
if (!occurrence) continue
index.sorted.push(occurrence)
index.byEntity.set(String(entity.identifier), [occurrence])
appendToDayBuckets(index, occurrence)
}
sortOccurrences(index.sorted)
for (const bucket of index.byDay.values()) sortOccurrences(bucket)
return index
}
export function occurrencesForDay(
index: CalendarOccurrenceIndex,
date: Date | number,
): CalendarOccurrence[] {
const dayKey = startOfLocalDay(new Date(date)).getTime()
return index.byDay.get(dayKey) ?? []
}
export function occurrencesForRange(
index: CalendarOccurrenceIndex,
range: VisibleDateRange,
): CalendarOccurrence[] {
if (range.endMs <= range.startMs) return []
const matches = new Map<string, CalendarOccurrence>()
let day = startOfLocalDay(new Date(range.startMs))
while (day.getTime() < range.endMs) {
for (const occurrence of index.byDay.get(day.getTime()) ?? []) {
if (occurrence.startMs < range.endMs && occurrence.endMs > range.startMs) {
matches.set(occurrence.key, occurrence)
}
}
day = startOfLocalDay(shiftLocalDays(day, 1))
}
const occurrences = Array.from(matches.values())
sortOccurrences(occurrences)
return occurrences
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import type { EntityObject } from '../../../../chrono_manager/src/models/entity'
import {
buildOccurrenceIndex,
occurrencesForDay,
occurrencesForRange,
} from '../../../src/utils/occurrenceIndex'
import { daysVisibleRange } from '../../../src/utils/dateRanges'
function localIso(year: number, month: number, day: number, hour = 0): string {
return new Date(year, month, day, hour).toISOString()
}
function eventEntity(
identifier: string,
startsOn: string | null,
endsOn: string | null,
timeless = false,
): EntityObject {
return {
identifier,
properties: {
startsOn,
endsOn,
timeless,
},
} as unknown as EntityObject
}
describe('occurrence index', () => {
it('normalizes and sorts event timestamps once', () => {
const later = eventEntity('later', localIso(2026, 5, 15, 13), localIso(2026, 5, 15, 14))
const earlier = eventEntity('earlier', localIso(2026, 5, 15, 9), localIso(2026, 5, 15, 10))
const invalid = eventEntity('invalid', 'not-a-date', null)
const index = buildOccurrenceIndex([later, invalid, earlier])
expect(index.sorted.map(occurrence => occurrence.entity.identifier)).toEqual(['earlier', 'later'])
expect(index.byEntity.get('earlier')?.[0]?.durationMs).toBe(60 * 60 * 1000)
expect(index.byEntity.has('invalid')).toBe(false)
})
it('places an overlapping event in each occupied day bucket', () => {
const overnight = eventEntity(
'overnight',
localIso(2026, 5, 15, 23),
localIso(2026, 5, 16, 1),
)
const index = buildOccurrenceIndex([overnight])
expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1)
expect(occurrencesForDay(index, new Date(2026, 5, 16))).toHaveLength(1)
expect(index.sorted[0].multiDay).toBe(true)
expect(index.sorted[0].timeless).toBe(true)
})
it('honors an exclusive midnight end', () => {
const allDay = eventEntity(
'all-day',
localIso(2026, 5, 20),
localIso(2026, 5, 21),
true,
)
const index = buildOccurrenceIndex([allDay])
expect(occurrencesForDay(index, new Date(2026, 5, 20))).toHaveLength(1)
expect(occurrencesForDay(index, new Date(2026, 5, 21))).toHaveLength(0)
})
it('retrieves overlapping occurrences without duplicates', () => {
const spanning = eventEntity(
'spanning',
localIso(2026, 5, 14),
localIso(2026, 5, 18),
true,
)
const outside = eventEntity('outside', localIso(2026, 6, 1), localIso(2026, 6, 1, 1))
const index = buildOccurrenceIndex([outside, spanning])
const occurrences = occurrencesForRange(index, daysVisibleRange(new Date(2026, 5, 15), 2))
expect(occurrences.map(occurrence => occurrence.entity.identifier)).toEqual(['spanning'])
})
it('gives a zero-duration event a queryable minimum duration', () => {
const instant = eventEntity('instant', localIso(2026, 5, 15, 9), null)
const index = buildOccurrenceIndex([instant])
expect(index.sorted[0].durationMs).toBe(1)
expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1)
})
})