feat: build the occurrence index
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</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-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="entity in dayEvents"
|
v-for="occurrence in dayOccurrences"
|
||||||
:key="entity.identifier"
|
:key="occurrence.key"
|
||||||
@click="$emit('event-click', entity)"
|
@click="$emit('event-click', occurrence.entity)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon :color="getEventColor(entity)">mdi-circle</v-icon>
|
<v-icon :color="getEventColor(occurrence)">mdi-circle</v-icon>
|
||||||
</template>
|
</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>
|
<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-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -54,12 +54,14 @@ import type { EntityObject } from '@ChronoManager/models/entity';
|
|||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
import { EventObject } from '@ChronoManager/models/event';
|
import { EventObject } from '@ChronoManager/models/event';
|
||||||
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
|
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
|
||||||
|
import { occurrencesForRange } from '@/utils/occurrenceIndex';
|
||||||
|
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
||||||
|
|
||||||
type CalendarEntity = EntityObject;
|
type CalendarEntity = EntityObject;
|
||||||
type CalendarCollection = CollectionObject;
|
type CalendarCollection = CollectionObject;
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
events: CalendarEntity[];
|
occurrenceIndex: CalendarOccurrenceIndex;
|
||||||
calendars: CalendarCollection[];
|
calendars: CalendarCollection[];
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
initialSpan?: AgendaViewSpan;
|
initialSpan?: AgendaViewSpan;
|
||||||
@@ -126,32 +128,27 @@ const dateRangeLabel = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupedEvents = computed(() => {
|
const groupedEvents = computed(() => {
|
||||||
const grouped: Record<string, CalendarEntity[]> = {};
|
const grouped: Record<string, CalendarOccurrence[]> = {};
|
||||||
const { start, end } = dateRange.value;
|
const { start, end } = dateRange.value;
|
||||||
|
|
||||||
const filtered = props.events.filter((e): e is CalendarEntity => {
|
const occurrences = occurrencesForRange(props.occurrenceIndex, {
|
||||||
const startsOn = getEventProperties(e).startsOn;
|
startMs: start.getTime(),
|
||||||
if (typeof startsOn !== 'string') return false;
|
endMs: end.getTime(),
|
||||||
const eventStart = new Date(startsOn);
|
|
||||||
return eventStart >= start && eventStart < end;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sorted = filtered.sort((a, b) =>
|
occurrences.forEach(occurrence => {
|
||||||
new Date(getEventProperties(a).startsOn || 0).getTime() - new Date(getEventProperties(b).startsOn || 0).getTime()
|
const dateKey = new Date(occurrence.startMs).toDateString();
|
||||||
);
|
|
||||||
|
|
||||||
sorted.forEach(entity => {
|
|
||||||
const dateKey = new Date(getEventProperties(entity).startsOn || 0).toDateString();
|
|
||||||
if (!grouped[dateKey]) {
|
if (!grouped[dateKey]) {
|
||||||
grouped[dateKey] = [];
|
grouped[dateKey] = [];
|
||||||
}
|
}
|
||||||
grouped[dateKey].push(entity);
|
grouped[dateKey].push(occurrence);
|
||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getEventColor(entity: CalendarEntity): string {
|
function getEventColor(occurrence: CalendarOccurrence): string {
|
||||||
|
const entity = occurrence.entity;
|
||||||
const event = getEventProperties(entity);
|
const event = getEventProperties(entity);
|
||||||
if (event.color) return event.color;
|
if (event.color) return event.color;
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
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' });
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEventDateTime(value: string | null): string {
|
function formatOccurrenceTime(timestamp: number): string {
|
||||||
return value ? formatTime(new Date(value)) : '';
|
return formatTime(new Date(timestamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAgendaDate(dateString: string): string {
|
function formatAgendaDate(dateString: string): string {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<MonthView
|
<MonthView
|
||||||
v-if="view === 'month'"
|
v-if="view === 'month'"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:events="events"
|
:occurrence-index="occurrenceIndex"
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
@event-click="$emit('event-click', $event)"
|
@event-click="$emit('event-click', $event)"
|
||||||
@date-click="$emit('date-click', $event)"
|
@date-click="$emit('date-click', $event)"
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<DaysView
|
<DaysView
|
||||||
v-else-if="view === 'days'"
|
v-else-if="view === 'days'"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:events="events"
|
:occurrence-index="occurrenceIndex"
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
:initial-span="initialDaysSpan"
|
:initial-span="initialDaysSpan"
|
||||||
@event-click="$emit('event-click', $event)"
|
@event-click="$emit('event-click', $event)"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
/>
|
/>
|
||||||
<AgendaView
|
<AgendaView
|
||||||
v-else-if="view === 'agenda'"
|
v-else-if="view === 'agenda'"
|
||||||
:events="events"
|
:occurrence-index="occurrenceIndex"
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:initial-span="initialAgendaViewSpan"
|
: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 { EntityObject } from '@ChronoManager/models/entity';
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
import type { VisibleDateRange } from '@/types';
|
import type { VisibleDateRange } from '@/types';
|
||||||
|
import type { CalendarOccurrenceIndex } from '@/types/occurrence';
|
||||||
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/dateRanges';
|
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/dateRanges';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
view: 'days' | 'month' | 'agenda';
|
view: 'days' | 'month' | 'agenda';
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
events: EntityObject[];
|
occurrenceIndex: CalendarOccurrenceIndex;
|
||||||
calendars: CollectionObject[];
|
calendars: CollectionObject[];
|
||||||
initialDaysSpan?: DaysViewSpan;
|
initialDaysSpan?: DaysViewSpan;
|
||||||
initialAgendaViewSpan?: AgendaViewSpan;
|
initialAgendaViewSpan?: AgendaViewSpan;
|
||||||
|
|||||||
+30
-30
@@ -45,18 +45,18 @@
|
|||||||
<div class="all-day-events-overlay">
|
<div class="all-day-events-overlay">
|
||||||
<div
|
<div
|
||||||
v-for="segment in allDaySegments.segments"
|
v-for="segment in allDaySegments.segments"
|
||||||
:key="segment.entity.identifier"
|
:key="segment.occurrence.key"
|
||||||
class="all-day-event"
|
class="all-day-event"
|
||||||
:class="{
|
:class="{
|
||||||
'is-start': segment.isStart,
|
'is-start': segment.isStart,
|
||||||
'is-end': segment.isEnd,
|
'is-end': segment.isEnd,
|
||||||
}"
|
}"
|
||||||
:style="getAllDayEventStyle(segment)"
|
:style="getAllDayEventStyle(segment)"
|
||||||
@click="emit('event-click', segment.entity)"
|
@click="emit('event-click', segment.occurrence.entity)"
|
||||||
@mouseenter="emit('event-hover', { event: $event, entity: segment.entity })"
|
@mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
|
||||||
@mouseleave="emit('event-hover-end')"
|
@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>
|
</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 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 class="day-events" @click="handleDayClick($event, day)">
|
||||||
<div
|
<div
|
||||||
v-for="entity in getTimedEvents(day)"
|
v-for="occurrence in getTimedEvents(day)"
|
||||||
:key="entity.identifier"
|
:key="occurrence.key"
|
||||||
class="day-event"
|
class="day-event"
|
||||||
:style="getEventStyle(entity)"
|
:style="getEventStyle(occurrence)"
|
||||||
@click.stop="emit('event-click', entity)"
|
@click.stop="emit('event-click', occurrence.entity)"
|
||||||
@mouseenter="emit('event-hover', { event: $event, entity })"
|
@mouseenter="emit('event-hover', { event: $event, entity: occurrence.entity })"
|
||||||
@mouseleave="emit('event-hover-end')"
|
@mouseleave="emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
<template v-if="daysCount <= 3">
|
<template v-if="daysCount <= 3">
|
||||||
<div class="event-time">
|
<div class="event-time">
|
||||||
{{ formatEventDateTime(getEventProperties(entity).startsOn) }} - {{ formatEventDateTime(getEventProperties(entity).endsOn) }}
|
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-title">{{ getEventProperties(entity).label || 'Untitled' }}</div>
|
<div class="event-title">{{ getEventProperties(occurrence.entity).label || 'Untitled' }}</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +104,6 @@ import { ref, computed, watch } from 'vue';
|
|||||||
import {
|
import {
|
||||||
startOfDay,
|
startOfDay,
|
||||||
getMultiDaySegments,
|
getMultiDaySegments,
|
||||||
getTimedEventsForDate,
|
|
||||||
type MultiDaySegment,
|
type MultiDaySegment,
|
||||||
} from '@/utils/calendarHelpers';
|
} from '@/utils/calendarHelpers';
|
||||||
import { shiftLocalDays } from '@/utils/dateRanges';
|
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 { EntityObject } from '@ChronoManager/models/entity';
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
import { EventObject } from '@ChronoManager/models/event';
|
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;
|
const ALL_DAY_EVENT_HEIGHT = 24;
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ type CalendarCollection = CollectionObject;
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
events: CalendarEntity[];
|
occurrenceIndex: CalendarOccurrenceIndex;
|
||||||
calendars: CalendarCollection[];
|
calendars: CalendarCollection[];
|
||||||
initialSpan?: DaysViewSpan;
|
initialSpan?: DaysViewSpan;
|
||||||
}>();
|
}>();
|
||||||
@@ -205,7 +206,12 @@ const allDaySegments = computed(() => {
|
|||||||
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
|
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 {
|
function isToday(date: Date): boolean {
|
||||||
@@ -213,8 +219,8 @@ function isToday(date: Date): boolean {
|
|||||||
return date.toDateString() === today.toDateString();
|
return date.toDateString() === today.toDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimedEvents(date: Date): CalendarEntity[] {
|
function getTimedEvents(date: Date): CalendarOccurrence[] {
|
||||||
return getTimedEventsForDate(props.events, date) as CalendarEntity[];
|
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventProperties(entity: CalendarEntity): EventObject {
|
function getEventProperties(entity: CalendarEntity): EventObject {
|
||||||
@@ -230,28 +236,22 @@ function getEventColor(entity: CalendarEntity): string {
|
|||||||
|
|
||||||
function getAllDayEventStyle(segment: MultiDaySegment) {
|
function getAllDayEventStyle(segment: MultiDaySegment) {
|
||||||
return {
|
return {
|
||||||
backgroundColor: getEventColor(segment.entity),
|
backgroundColor: getEventColor(segment.occurrence.entity),
|
||||||
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
||||||
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
||||||
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventStyle(entity: CalendarEntity) {
|
function getEventStyle(occurrence: CalendarOccurrence) {
|
||||||
const event = getEventProperties(entity);
|
const startTime = new Date(occurrence.startMs);
|
||||||
|
|
||||||
if (!event.startsOn || !event.endsOn) {
|
|
||||||
return { display: 'none' };
|
|
||||||
}
|
|
||||||
const startTime = new Date(event.startsOn);
|
|
||||||
const endTime = new Date(event.endsOn);
|
|
||||||
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
||||||
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
|
const duration = occurrence.durationMs / (1000 * 60);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: `${(start / 1440) * 100}%`,
|
top: `${(start / 1440) * 100}%`,
|
||||||
height: `${(duration / 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' });
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEventDateTime(value: string | null): string {
|
function formatOccurrenceTime(timestamp: number): string {
|
||||||
return value ? formatTime(new Date(value)) : '';
|
return formatTime(new Date(timestamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWeekDay(date: Date): string {
|
function formatWeekDay(date: Date): string {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||||
import {
|
import {
|
||||||
startOfDay,
|
startOfDay,
|
||||||
daysDiff,
|
getMultiDaySegments,
|
||||||
isMultiDay,
|
|
||||||
getSingleDayEventsForDate,
|
|
||||||
type MultiDaySegment,
|
type MultiDaySegment,
|
||||||
} from '@/utils/calendarHelpers';
|
} from '@/utils/calendarHelpers';
|
||||||
import { shiftLocalMonths } from '@/utils/dateRanges';
|
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 EVENT_HEIGHT = 22; // Height of each event row in pixels
|
||||||
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
|
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
|
||||||
@@ -21,7 +21,7 @@ interface WeekData {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
events: any[];
|
occurrenceIndex: CalendarOccurrenceIndex;
|
||||||
calendars: any[];
|
calendars: any[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -113,7 +113,21 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get multiday segments for this week
|
// 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({
|
weeksData.push({
|
||||||
startDate: weekStart,
|
startDate: weekStart,
|
||||||
@@ -126,77 +140,9 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
return weeksData;
|
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
|
// Single-day events for a specific date
|
||||||
function getSingleDayEvents(date: Date): any[] {
|
function getSingleDayEvents(date: Date): CalendarOccurrence[] {
|
||||||
return getSingleDayEventsForDate(props.events, date);
|
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.multiDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
|
// 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();
|
return date.toDateString() === today.toDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventColor(entity: any): string {
|
function getEventColor(entity: CalendarOccurrence['entity']): string {
|
||||||
if (entity.properties?.color) return entity.properties.color;
|
if (entity.properties?.color) return entity.properties.color;
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
||||||
return calendar?.properties?.color || '#1976D2';
|
return calendar?.properties?.color || '#1976D2';
|
||||||
@@ -265,15 +211,15 @@ function getEventColor(entity: any): string {
|
|||||||
<!-- Single-day events -->
|
<!-- Single-day events -->
|
||||||
<div class="cell-events">
|
<div class="cell-events">
|
||||||
<div
|
<div
|
||||||
v-for="entity in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
v-for="occurrence in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
||||||
:key="entity.identifier"
|
:key="occurrence.key"
|
||||||
class="single-day-event"
|
class="single-day-event"
|
||||||
:style="{ backgroundColor: getEventColor(entity) }"
|
:style="{ backgroundColor: getEventColor(occurrence.entity) }"
|
||||||
@click.stop="$emit('event-click', entity)"
|
@click.stop="$emit('event-click', occurrence.entity)"
|
||||||
@mouseenter="$emit('event-hover', { event: $event, entity })"
|
@mouseenter="$emit('event-hover', { event: $event, entity: occurrence.entity })"
|
||||||
@mouseleave="$emit('event-hover-end')"
|
@mouseleave="$emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
{{ entity.properties?.label || 'Untitled' }}
|
{{ occurrence.entity.properties?.label || 'Untitled' }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
||||||
@@ -290,24 +236,24 @@ function getEventColor(entity: any): string {
|
|||||||
<div class="multiday-overlay">
|
<div class="multiday-overlay">
|
||||||
<div
|
<div
|
||||||
v-for="segment in week.multiDaySegments"
|
v-for="segment in week.multiDaySegments"
|
||||||
:key="`${segment.entity.identifier}-${weekIndex}`"
|
:key="`${segment.occurrence.key}-${weekIndex}`"
|
||||||
class="multiday-event"
|
class="multiday-event"
|
||||||
:class="{
|
:class="{
|
||||||
'is-start': segment.isStart,
|
'is-start': segment.isStart,
|
||||||
'is-end': segment.isEnd,
|
'is-end': segment.isEnd,
|
||||||
}"
|
}"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: getEventColor(segment.entity),
|
backgroundColor: getEventColor(segment.occurrence.entity),
|
||||||
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
||||||
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
||||||
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
|
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
|
||||||
}"
|
}"
|
||||||
@click.stop="$emit('event-click', segment.entity)"
|
@click.stop="$emit('event-click', segment.occurrence.entity)"
|
||||||
@mouseenter="$emit('event-hover', { event: $event, entity: segment.entity })"
|
@mouseenter="$emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
|
||||||
@mouseleave="$emit('event-hover-end')"
|
@mouseleave="$emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
<span v-if="segment.isStart" class="event-label">
|
<span v-if="segment.isStart" class="event-label">
|
||||||
{{ segment.entity.properties?.label || 'Untitled' }}
|
{{ segment.occurrence.entity.properties?.label || 'Untitled' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ const {
|
|||||||
loading,
|
loading,
|
||||||
calendars,
|
calendars,
|
||||||
taskLists,
|
taskLists,
|
||||||
filteredEvents,
|
|
||||||
filteredTasks,
|
filteredTasks,
|
||||||
collections,
|
collections,
|
||||||
|
occurrenceIndex,
|
||||||
} = storeToRefs(chronoOperationsStore);
|
} = storeToRefs(chronoOperationsStore);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -277,7 +277,7 @@ watch(() => route.fullPath, () => {
|
|||||||
v-if="!isTaskView"
|
v-if="!isTaskView"
|
||||||
:view="calendarView"
|
:view="calendarView"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:events="filteredEvents"
|
:occurrence-index="occurrenceIndex"
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
:initial-days-span="daysViewSpan"
|
:initial-days-span="daysViewSpan"
|
||||||
:initial-agenda-view-span="agendaViewSpan"
|
:initial-agenda-view-span="agendaViewSpan"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
|||||||
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
||||||
import { useImportStore } from '@ChronoManager/stores/importStore'
|
import { useImportStore } from '@ChronoManager/stores/importStore'
|
||||||
import type { VisibleDateRange } from '@/types'
|
import type { VisibleDateRange } from '@/types'
|
||||||
|
import { buildOccurrenceIndex, occurrencesForRange } from '@/utils/occurrenceIndex'
|
||||||
|
|
||||||
type ChronoEntityProperties = EventObject | TaskObject
|
type ChronoEntityProperties = EventObject | TaskObject
|
||||||
|
|
||||||
@@ -44,6 +45,10 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
|
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
|
||||||
})
|
})
|
||||||
const filteredTasks = computed(() => tasks.value)
|
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) {
|
function setVisibleRange(range: VisibleDateRange) {
|
||||||
if (
|
if (
|
||||||
@@ -176,6 +181,8 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
tasks,
|
tasks,
|
||||||
filteredEvents,
|
filteredEvents,
|
||||||
filteredTasks,
|
filteredTasks,
|
||||||
|
occurrenceIndex,
|
||||||
|
visibleOccurrences,
|
||||||
setVisibleRange,
|
setVisibleRange,
|
||||||
saveEntity,
|
saveEntity,
|
||||||
deleteEntity,
|
deleteEntity,
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
* Shared logic for multi-day event handling across calendar views
|
* Shared logic for multi-day event handling across calendar views
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { CalendarOccurrence } from '@/types/occurrence'
|
||||||
|
|
||||||
export interface MultiDaySegment {
|
export interface MultiDaySegment {
|
||||||
entity: any;
|
occurrence: CalendarOccurrence;
|
||||||
startCol: number;
|
startCol: number;
|
||||||
span: number;
|
span: number;
|
||||||
lane: number;
|
lane: number;
|
||||||
@@ -85,7 +87,7 @@ export function eventOnDate(entity: any, date: Date): boolean {
|
|||||||
* Used for rendering spanning events across columns
|
* Used for rendering spanning events across columns
|
||||||
*/
|
*/
|
||||||
export function getMultiDaySegments(
|
export function getMultiDaySegments(
|
||||||
events: any[],
|
occurrences: CalendarOccurrence[],
|
||||||
rangeStart: Date,
|
rangeStart: Date,
|
||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
columnCount: number,
|
columnCount: number,
|
||||||
@@ -94,30 +96,28 @@ export function getMultiDaySegments(
|
|||||||
const segments: MultiDaySegment[] = [];
|
const segments: MultiDaySegment[] = [];
|
||||||
|
|
||||||
// Filter multi-day/all-day events that overlap this range
|
// Filter multi-day/all-day events that overlap this range
|
||||||
const multiDayEvents = events.filter(entity => {
|
const rangeStartMs = startOfDay(rangeStart).getTime();
|
||||||
if (!entity.properties?.startsOn) return false;
|
const rangeEndExclusive = startOfDay(new Date(rangeEnd));
|
||||||
if (!isAllDay(entity)) return false;
|
rangeEndExclusive.setDate(rangeEndExclusive.getDate() + 1);
|
||||||
return eventOverlapsRange(entity, rangeStart, rangeEnd);
|
|
||||||
|
const multiDayEvents = occurrences.filter(occurrence => {
|
||||||
|
return occurrence.timeless
|
||||||
|
&& occurrence.startMs < rangeEndExclusive.getTime()
|
||||||
|
&& occurrence.endMs > rangeStartMs;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort: longer events first, then by start date
|
// Sort: longer events first, then by start date
|
||||||
multiDayEvents.sort((a, b) => {
|
multiDayEvents.sort((a, b) => {
|
||||||
const aStart = new Date(a.properties.startsOn);
|
if (b.durationMs !== a.durationMs) return b.durationMs - a.durationMs;
|
||||||
const aEnd = new Date(a.properties.endsOn || a.properties.startsOn);
|
return a.startMs - b.startMs;
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lane assignment - track which days are occupied in each lane
|
// Lane assignment - track which days are occupied in each lane
|
||||||
const lanes: boolean[][] = [];
|
const lanes: boolean[][] = [];
|
||||||
|
|
||||||
for (const entity of multiDayEvents) {
|
for (const occurrence of multiDayEvents) {
|
||||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
const eventStart = startOfDay(new Date(occurrence.startMs));
|
||||||
const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
|
const eventEnd = startOfDay(new Date(occurrence.endMs - 1));
|
||||||
|
|
||||||
// Clamp to visible range
|
// Clamp to visible range
|
||||||
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
||||||
@@ -156,7 +156,7 @@ export function getMultiDaySegments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
segments.push({
|
segments.push({
|
||||||
entity,
|
occurrence,
|
||||||
startCol,
|
startCol,
|
||||||
span,
|
span,
|
||||||
lane,
|
lane,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user