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>
|
||||
</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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
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,
|
||||
|
||||
@@ -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