refactor: event instances
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="(dayOccurrences, date) in groupedEvents" :key="date">
|
||||
<template v-for="(dayInstances, date) in groupedEvents" :key="date">
|
||||
<v-list-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
|
||||
<v-list-item
|
||||
v-for="occurrence in dayOccurrences"
|
||||
:key="occurrence.key"
|
||||
@click="$emit('event-click', occurrence.entity)"
|
||||
v-for="instance in dayInstances"
|
||||
:key="instance.key"
|
||||
@click="$emit('event-click', instance.entity)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="getEventColor(occurrence)">mdi-circle</v-icon>
|
||||
<v-icon :color="getEventColor(instance)">mdi-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ occurrence.label || 'Untitled' }}</v-list-item-title>
|
||||
<v-list-item-title>{{ instance.label || 'Untitled' }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ occurrence.timeless ? 'All day' : `${formatOccurrenceTime(occurrence.startMs)} - ${formatOccurrenceTime(occurrence.endMs)}` }}
|
||||
{{ instance.timeless ? 'All day' : `${formatInstanceTime(instance.startMs)} - ${formatInstanceTime(instance.endMs)}` }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -52,20 +52,21 @@ import { ref, computed, watch } from 'vue';
|
||||
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
|
||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
|
||||
import { occurrencesForRange } from '@/utils/occurrenceIndex';
|
||||
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
||||
import { daysVisibleRange, shiftLocalDays } from '@/utils/date';
|
||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||
import type { CalendarInstance } from '@/types/instance';
|
||||
|
||||
type CalendarEntity = EntityObject;
|
||||
type CalendarCollection = CollectionObject;
|
||||
|
||||
const props = defineProps<{
|
||||
occurrenceIndex: CalendarOccurrenceIndex;
|
||||
calendars: CalendarCollection[];
|
||||
currentDate: Date;
|
||||
initialSpan?: AgendaViewSpan;
|
||||
}>();
|
||||
|
||||
const instancesStore = useChronoInstancesStore();
|
||||
|
||||
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -127,28 +128,28 @@ const dateRangeLabel = computed(() => {
|
||||
});
|
||||
|
||||
const groupedEvents = computed(() => {
|
||||
const grouped: Record<string, CalendarOccurrence[]> = {};
|
||||
const grouped: Record<string, CalendarInstance[]> = {};
|
||||
const { start, end } = dateRange.value;
|
||||
|
||||
const occurrences = occurrencesForRange(props.occurrenceIndex, {
|
||||
const instances = instancesStore.instancesForRange({
|
||||
startMs: start.getTime(),
|
||||
endMs: end.getTime(),
|
||||
});
|
||||
|
||||
occurrences.forEach(occurrence => {
|
||||
const dateKey = new Date(occurrence.startMs).toDateString();
|
||||
instances.forEach(instance => {
|
||||
const dateKey = new Date(instance.startMs).toDateString();
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = [];
|
||||
}
|
||||
grouped[dateKey].push(occurrence);
|
||||
grouped[dateKey].push(instance);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
const entity = occurrence.entity;
|
||||
if (occurrence.color) return occurrence.color;
|
||||
function getEventColor(instance: CalendarInstance): string {
|
||||
const entity = instance.entity;
|
||||
if (instance.color) return instance.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
||||
return calendar?.properties?.color || '#1976D2';
|
||||
}
|
||||
@@ -157,7 +158,7 @@ function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatOccurrenceTime(timestamp: number): string {
|
||||
function formatInstanceTime(timestamp: number): string {
|
||||
return formatTime(new Date(timestamp));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<MonthView
|
||||
v-if="view === 'month'"
|
||||
:current-date="currentDate"
|
||||
:occurrence-index="occurrenceIndex"
|
||||
:calendars="calendars"
|
||||
@event-click="$emit('event-click', $event)"
|
||||
@date-click="$emit('date-click', $event)"
|
||||
@@ -20,7 +19,6 @@
|
||||
<DaysView
|
||||
v-else-if="view === 'days'"
|
||||
:current-date="currentDate"
|
||||
:occurrence-index="occurrenceIndex"
|
||||
:calendars="calendars"
|
||||
:initial-span="initialDaysSpan"
|
||||
@event-click="$emit('event-click', $event)"
|
||||
@@ -32,7 +30,6 @@
|
||||
/>
|
||||
<AgendaView
|
||||
v-else-if="view === 'agenda'"
|
||||
:occurrence-index="occurrenceIndex"
|
||||
:calendars="calendars"
|
||||
:current-date="currentDate"
|
||||
:initial-span="initialAgendaViewSpan"
|
||||
@@ -53,13 +50,11 @@ 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';
|
||||
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/date.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
view: 'days' | 'month' | 'agenda';
|
||||
currentDate: Date;
|
||||
occurrenceIndex: CalendarOccurrenceIndex;
|
||||
calendars: CollectionObject[];
|
||||
initialDaysSpan?: DaysViewSpan;
|
||||
initialAgendaViewSpan?: AgendaViewSpan;
|
||||
|
||||
+35
-38
@@ -45,18 +45,18 @@
|
||||
<div class="all-day-events-overlay">
|
||||
<div
|
||||
v-for="segment in allDaySegments.segments"
|
||||
:key="segment.occurrence.key"
|
||||
:key="segment.instance.key"
|
||||
class="all-day-event"
|
||||
:class="{
|
||||
'is-start': segment.isStart,
|
||||
'is-end': segment.isEnd,
|
||||
}"
|
||||
:style="getAllDayEventStyle(segment)"
|
||||
@click="emit('event-click', segment.occurrence.entity)"
|
||||
@mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
|
||||
@click="emit('event-click', segment.instance.entity)"
|
||||
@mouseenter="emit('event-hover', { event: $event, entity: segment.instance.entity })"
|
||||
@mouseleave="emit('event-hover-end')"
|
||||
>
|
||||
<span v-if="segment.isStart" class="event-label">{{ segment.occurrence.label || 'Untitled' }}</span>
|
||||
<span v-if="segment.isStart" class="event-label">{{ segment.instance.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="occurrence in getTimedEvents(day)"
|
||||
:key="occurrence.key"
|
||||
v-for="instance in getTimedEvents(day)"
|
||||
:key="instance.key"
|
||||
class="day-event"
|
||||
:style="getEventStyle(occurrence)"
|
||||
@click.stop="emit('event-click', occurrence.entity)"
|
||||
@mouseenter="emit('event-hover', { event: $event, entity: occurrence.entity })"
|
||||
:style="getEventStyle(instance)"
|
||||
@click.stop="emit('event-click', instance.entity)"
|
||||
@mouseenter="emit('event-hover', { event: $event, entity: instance.entity })"
|
||||
@mouseleave="emit('event-hover-end')"
|
||||
>
|
||||
<template v-if="daysCount <= 3">
|
||||
<div class="event-time">
|
||||
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
|
||||
{{ formatInstanceTime(instance.startMs) }} - {{ formatInstanceTime(instance.endMs) }}
|
||||
</div>
|
||||
<div class="event-title">{{ occurrence.label || 'Untitled' }}</div>
|
||||
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="event-title-compact">{{ occurrence.label || 'Untitled' }}</div>
|
||||
<div class="event-title-compact">{{ instance.label || 'Untitled' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,17 +101,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import {
|
||||
startOfDay,
|
||||
getMultiDaySegments,
|
||||
type MultiDaySegment,
|
||||
} from '@/utils/calendarHelpers';
|
||||
import { shiftLocalDays } from '@/utils/dateRanges';
|
||||
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
||||
import { shiftLocalDays, startOfLocalDay } from '@/utils/date';
|
||||
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 { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
|
||||
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||
import type { CalendarInstance } from '@/types/instance';
|
||||
|
||||
const ALL_DAY_EVENT_HEIGHT = 24;
|
||||
|
||||
@@ -120,11 +116,12 @@ type CalendarCollection = CollectionObject;
|
||||
|
||||
const props = defineProps<{
|
||||
currentDate: Date;
|
||||
occurrenceIndex: CalendarOccurrenceIndex;
|
||||
calendars: CalendarCollection[];
|
||||
initialSpan?: DaysViewSpan;
|
||||
}>();
|
||||
|
||||
const instancesStore = useChronoInstancesStore();
|
||||
|
||||
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
|
||||
const daysCount = computed(() => spanToDays(selectedSpan.value));
|
||||
|
||||
@@ -197,20 +194,20 @@ const allDaySegments = computed(() => {
|
||||
return { segments: [], laneCount: 0 };
|
||||
}
|
||||
|
||||
const rangeStart = startOfDay(visibleDates.value[0]);
|
||||
const rangeEnd = startOfDay(visibleDates.value[visibleDates.value.length - 1]);
|
||||
const rangeStart = startOfLocalDay(visibleDates.value[0]);
|
||||
const rangeEnd = startOfLocalDay(visibleDates.value[visibleDates.value.length - 1]);
|
||||
|
||||
const getColumnIndex = (date: Date): number => {
|
||||
const targetDay = startOfDay(date);
|
||||
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
|
||||
const targetDay = startOfLocalDay(date);
|
||||
return visibleDates.value.findIndex(d => startOfLocalDay(d).getTime() === targetDay.getTime());
|
||||
};
|
||||
|
||||
const occurrences = occurrencesForRange(props.occurrenceIndex, {
|
||||
const instances = instancesStore.instancesForRange({
|
||||
startMs: rangeStart.getTime(),
|
||||
endMs: shiftLocalDays(rangeEnd, 1).getTime(),
|
||||
});
|
||||
|
||||
return getMultiDaySegments(occurrences, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
|
||||
return layoutMultiDayLanes(instances, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
|
||||
});
|
||||
|
||||
function isToday(date: Date): boolean {
|
||||
@@ -218,34 +215,34 @@ function isToday(date: Date): boolean {
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
function getTimedEvents(date: Date): CalendarOccurrence[] {
|
||||
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
|
||||
function getTimedEvents(date: Date): CalendarInstance[] {
|
||||
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
|
||||
}
|
||||
|
||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
if (occurrence.color) return occurrence.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
|
||||
function getEventColor(instance: CalendarInstance): string {
|
||||
if (instance.color) return instance.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
|
||||
return calendar?.properties?.color || '#1976D2';
|
||||
}
|
||||
|
||||
function getAllDayEventStyle(segment: MultiDaySegment) {
|
||||
return {
|
||||
backgroundColor: getEventColor(segment.occurrence),
|
||||
backgroundColor: getEventColor(segment.instance),
|
||||
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(occurrence: CalendarOccurrence) {
|
||||
const startTime = new Date(occurrence.startMs);
|
||||
function getEventStyle(instance: CalendarInstance) {
|
||||
const startTime = new Date(instance.startMs);
|
||||
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
||||
const duration = occurrence.durationMs / (1000 * 60);
|
||||
const duration = instance.durationMs / (1000 * 60);
|
||||
|
||||
return {
|
||||
top: `${(start / 1440) * 100}%`,
|
||||
height: `${(duration / 1440) * 100}%`,
|
||||
backgroundColor: getEventColor(occurrence),
|
||||
backgroundColor: getEventColor(instance),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +256,7 @@ function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatOccurrenceTime(timestamp: number): string {
|
||||
function formatInstanceTime(timestamp: number): string {
|
||||
return formatTime(new Date(timestamp));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { parseCalendarDate } from '@/utils/date'
|
||||
|
||||
const props = defineProps<{
|
||||
event: any | null
|
||||
@@ -11,19 +12,6 @@ const emit = defineEmits<{
|
||||
'click': [event: any]
|
||||
}>()
|
||||
|
||||
const formatDateTime = (isoString: string | null | undefined): string => {
|
||||
if (!isoString) return 'Not set'
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (isoString: string | null | undefined): string => {
|
||||
if (!isoString) return 'Not set'
|
||||
const date = new Date(isoString)
|
||||
@@ -33,6 +21,10 @@ const formatTime = (isoString: string | null | undefined): string => {
|
||||
})
|
||||
}
|
||||
|
||||
const formatAllDayDate = (isoString: string | null | undefined): string => {
|
||||
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
|
||||
}
|
||||
|
||||
const popupStyle = computed(() => {
|
||||
return {
|
||||
left: `${props.position.x}px`,
|
||||
@@ -83,7 +75,7 @@ const participantCount = computed(() => {
|
||||
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
|
||||
<div class="text-caption">
|
||||
<div v-if="event.properties?.timeless">
|
||||
All Day - {{ new Date(event.properties.startsOn).toLocaleDateString() }}
|
||||
All Day - {{ formatAllDayDate(event.properties.startsOn) }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ formatTime(event.properties?.startsOn) }} - {{ formatTime(event.properties?.endsOn) }}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
startOfDay,
|
||||
getMultiDaySegments,
|
||||
type MultiDaySegment,
|
||||
} from '@/utils/calendarHelpers';
|
||||
import { shiftLocalMonths } from '@/utils/dateRanges';
|
||||
import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
|
||||
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
||||
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
||||
import { shiftLocalMonths, startOfLocalDay } from '@/utils/date';
|
||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||
import type { CalendarInstance } from '@/types/instance';
|
||||
|
||||
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,10 +17,11 @@ interface WeekData {
|
||||
|
||||
const props = defineProps<{
|
||||
currentDate: Date;
|
||||
occurrenceIndex: CalendarOccurrenceIndex;
|
||||
calendars: any[];
|
||||
}>();
|
||||
|
||||
const instancesStore = useChronoInstancesStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'event-click': [event: any];
|
||||
'date-click': [date: Date];
|
||||
@@ -99,7 +96,7 @@ const weeks = computed<WeekData[]>(() => {
|
||||
const numWeeks = weeksNeeded.value;
|
||||
|
||||
for (let w = 0; w < numWeeks; w++) {
|
||||
const weekStart = startOfDay(new Date(current));
|
||||
const weekStart = startOfLocalDay(new Date(current));
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
|
||||
@@ -113,7 +110,7 @@ const weeks = computed<WeekData[]>(() => {
|
||||
}
|
||||
|
||||
// Get multiday segments for this week
|
||||
const occurrences = occurrencesForRange(props.occurrenceIndex, {
|
||||
const instances = instancesStore.instancesForRange({
|
||||
startMs: weekStart.getTime(),
|
||||
endMs: new Date(
|
||||
weekEnd.getFullYear(),
|
||||
@@ -121,8 +118,8 @@ const weeks = computed<WeekData[]>(() => {
|
||||
weekEnd.getDate() + 1,
|
||||
).getTime(),
|
||||
});
|
||||
const { segments, laneCount } = getMultiDaySegments(
|
||||
occurrences,
|
||||
const { segments, laneCount } = layoutMultiDayLanes(
|
||||
instances,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
7,
|
||||
@@ -141,8 +138,8 @@ const weeks = computed<WeekData[]>(() => {
|
||||
});
|
||||
|
||||
// Single-day events for a specific date
|
||||
function getSingleDayEvents(date: Date): CalendarOccurrence[] {
|
||||
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.multiDay);
|
||||
function getSingleDayEvents(date: Date): CalendarInstance[] {
|
||||
return instancesStore.instancesForDay(date).filter(instance => !instance.multiDay);
|
||||
}
|
||||
|
||||
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
|
||||
@@ -161,9 +158,9 @@ function isToday(date: Date): boolean {
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
if (occurrence.color) return occurrence.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
|
||||
function getEventColor(instance: CalendarInstance): string {
|
||||
if (instance.color) return instance.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
|
||||
return calendar?.properties?.color || '#1976D2';
|
||||
}
|
||||
</script>
|
||||
@@ -211,15 +208,15 @@ function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
<!-- Single-day events -->
|
||||
<div class="cell-events">
|
||||
<div
|
||||
v-for="occurrence in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
||||
:key="occurrence.key"
|
||||
v-for="instance in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
||||
:key="instance.key"
|
||||
class="single-day-event"
|
||||
:style="{ backgroundColor: getEventColor(occurrence) }"
|
||||
@click.stop="$emit('event-click', occurrence.entity)"
|
||||
@mouseenter="$emit('event-hover', { event: $event, entity: occurrence.entity })"
|
||||
:style="{ backgroundColor: getEventColor(instance) }"
|
||||
@click.stop="$emit('event-click', instance.entity)"
|
||||
@mouseenter="$emit('event-hover', { event: $event, entity: instance.entity })"
|
||||
@mouseleave="$emit('event-hover-end')"
|
||||
>
|
||||
{{ occurrence.label || 'Untitled' }}
|
||||
{{ instance.label || 'Untitled' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
||||
@@ -236,24 +233,24 @@ function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
<div class="multiday-overlay">
|
||||
<div
|
||||
v-for="segment in week.multiDaySegments"
|
||||
:key="`${segment.occurrence.key}-${weekIndex}`"
|
||||
:key="`${segment.instance.key}-${weekIndex}`"
|
||||
class="multiday-event"
|
||||
:class="{
|
||||
'is-start': segment.isStart,
|
||||
'is-end': segment.isEnd,
|
||||
}"
|
||||
:style="{
|
||||
backgroundColor: getEventColor(segment.occurrence),
|
||||
backgroundColor: getEventColor(segment.instance),
|
||||
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.occurrence.entity)"
|
||||
@mouseenter="$emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
|
||||
@click.stop="$emit('event-click', segment.instance.entity)"
|
||||
@mouseenter="$emit('event-hover', { event: $event, entity: segment.instance.entity })"
|
||||
@mouseleave="$emit('event-hover-end')"
|
||||
>
|
||||
<span v-if="segment.isStart" class="event-label">
|
||||
{{ segment.occurrence.label || 'Untitled' }}
|
||||
{{ segment.instance.label || 'Untitled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { parseCalendarDate } from '@/utils/date'
|
||||
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
@@ -42,6 +43,9 @@ const formatTime = (date: Date): string => {
|
||||
|
||||
const formatDateTime = (isoString: string | null | undefined): string => {
|
||||
if (!isoString) return 'Not set'
|
||||
if (timelessValue.value) {
|
||||
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
|
||||
}
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
@@ -53,6 +57,16 @@ const parseDateTime = (date: string, time: string): Date => {
|
||||
return new Date(`${date}T${time}`)
|
||||
}
|
||||
|
||||
const serializeDateTime = (date: string, time: string): string => {
|
||||
return timelessValue.value
|
||||
? `${date}T00:00:00.000Z`
|
||||
: new Date(`${date}T${time}`).toISOString()
|
||||
}
|
||||
|
||||
const inputDate = (value: string, timeless: boolean): string => {
|
||||
return timeless ? value.slice(0, 10) : formatDate(new Date(value))
|
||||
}
|
||||
|
||||
const formatDisplayDate = (value: string): string => {
|
||||
if (!value) return ''
|
||||
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
|
||||
@@ -101,10 +115,10 @@ const selectEndDate = (value: unknown) => {
|
||||
}
|
||||
|
||||
// Initialize date/time from props
|
||||
watch(() => props.startsOn, (newValue) => {
|
||||
watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
|
||||
if (newValue) {
|
||||
const start = new Date(newValue)
|
||||
startDate.value = formatDate(start)
|
||||
startDate.value = inputDate(newValue, timeless === true)
|
||||
startTime.value = formatTime(start)
|
||||
} else {
|
||||
const now = new Date()
|
||||
@@ -113,10 +127,10 @@ watch(() => props.startsOn, (newValue) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.endsOn, (newValue) => {
|
||||
watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
|
||||
if (newValue) {
|
||||
const end = new Date(newValue)
|
||||
endDate.value = formatDate(end)
|
||||
endDate.value = inputDate(newValue, timeless === true)
|
||||
endTime.value = formatTime(end)
|
||||
} else {
|
||||
const later = new Date(Date.now() + 60 * 60 * 1000)
|
||||
@@ -129,10 +143,14 @@ const ensureValidRange = (): boolean => {
|
||||
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) return false
|
||||
const start = parseDateTime(startDate.value, startTime.value)
|
||||
const end = parseDateTime(endDate.value, endTime.value)
|
||||
if (end.getTime() >= start.getTime()) return false
|
||||
const valid = timelessValue.value
|
||||
? end.getTime() > start.getTime()
|
||||
: end.getTime() >= start.getTime()
|
||||
if (valid) return false
|
||||
|
||||
const correctedEnd = new Date(start)
|
||||
if (!timelessValue.value) correctedEnd.setHours(correctedEnd.getHours() + 1)
|
||||
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
|
||||
else correctedEnd.setHours(correctedEnd.getHours() + 1)
|
||||
endDate.value = formatDate(correctedEnd)
|
||||
endTime.value = formatTime(correctedEnd)
|
||||
return true
|
||||
@@ -140,8 +158,8 @@ const ensureValidRange = (): boolean => {
|
||||
|
||||
watch([startDate, startTime, endDate, endTime, timelessValue], () => {
|
||||
if (props.mode !== 'edit' || ensureValidRange()) return
|
||||
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
|
||||
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
|
||||
emit('update:startsOn', serializeDateTime(startDate.value, startTime.value))
|
||||
emit('update:endsOn', serializeDateTime(endDate.value, endTime.value))
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useDisplay } from 'vuetify';
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
|
||||
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
|
||||
import { useChronoUiStore } from '@/stores/chronoUiStore';
|
||||
@@ -28,6 +29,7 @@ const isChronoManagerAvailable = computed(() => {
|
||||
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
||||
});
|
||||
|
||||
const chronoInstancesStore = useChronoInstancesStore();
|
||||
const chronoOperationsStore = useChronoOperationsStore();
|
||||
const chronoSettingsStore = useChronoSettingsStore();
|
||||
const chronoUiStore = useChronoUiStore();
|
||||
@@ -45,9 +47,10 @@ const {
|
||||
taskLists,
|
||||
filteredTasks,
|
||||
collections,
|
||||
occurrenceIndex,
|
||||
} = storeToRefs(chronoOperationsStore);
|
||||
|
||||
const { setVisibleRange } = chronoInstancesStore;
|
||||
|
||||
const {
|
||||
currentDate,
|
||||
sidebarVisible,
|
||||
@@ -91,7 +94,6 @@ const {
|
||||
} = chronoUiStore;
|
||||
|
||||
const {
|
||||
setVisibleRange,
|
||||
toggleCalendarVisibility,
|
||||
toggleTaskComplete,
|
||||
} = chronoOperationsStore;
|
||||
@@ -277,7 +279,6 @@ watch(() => route.fullPath, () => {
|
||||
v-if="!isTaskView"
|
||||
:view="calendarView"
|
||||
:current-date="currentDate"
|
||||
:occurrence-index="occurrenceIndex"
|
||||
:calendars="calendars"
|
||||
:initial-days-span="daysViewSpan"
|
||||
:initial-agenda-view-span="agendaViewSpan"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||
import type { VisibleDateRange } from '@/types'
|
||||
import type { CalendarInstance } from '@/types/instance'
|
||||
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore'
|
||||
import { expandEntityInstances } from '@/utils/recurrence'
|
||||
import { shiftLocalDays, startOfLocalDay } from '@/utils/date'
|
||||
|
||||
type InstancesByDay = Map<number, CalendarInstance[]>
|
||||
|
||||
interface ExpansionCacheEntry {
|
||||
entity: EntityObject
|
||||
instances: CalendarInstance[]
|
||||
}
|
||||
|
||||
function appendToDayBuckets(byDay: InstancesByDay, instance: CalendarInstance) {
|
||||
const lastOccupiedDayMs = startOfLocalDay(new Date(instance.endMs - 1)).getTime()
|
||||
let day = new Date(instance.dayStartMs)
|
||||
|
||||
while (day.getTime() <= lastOccupiedDayMs) {
|
||||
const dayKey = day.getTime()
|
||||
const bucket = byDay.get(dayKey)
|
||||
if (bucket) bucket.push(instance)
|
||||
else byDay.set(dayKey, [instance])
|
||||
day = startOfLocalDay(shiftLocalDays(day, 1))
|
||||
}
|
||||
}
|
||||
|
||||
function sortInstances(instances: CalendarInstance[]) {
|
||||
instances.sort((left, right) => (
|
||||
left.startMs - right.startMs
|
||||
|| right.durationMs - left.durationMs
|
||||
|| left.key.localeCompare(right.key)
|
||||
))
|
||||
}
|
||||
|
||||
export const useChronoInstancesStore = defineStore('chronoInstancesStore', () => {
|
||||
const operationsStore = useChronoOperationsStore()
|
||||
|
||||
const visibleRange = shallowRef<VisibleDateRange | null>(null)
|
||||
|
||||
// Expanded instances per entity, reused across index rebuilds so a change
|
||||
// to one entity only re-expands that entity. Entries are validated by
|
||||
// object identity: the entities store replaces the EntityObject on every
|
||||
// create/update, so in-place mutation of a stored entity is not observed.
|
||||
const expansionCache = new Map<string, ExpansionCacheEntry>()
|
||||
let expansionCacheRange: VisibleDateRange | null = null
|
||||
|
||||
const instancesByDay = computed<InstancesByDay>(() => {
|
||||
const range = visibleRange.value
|
||||
if (
|
||||
expansionCacheRange?.startMs !== range?.startMs
|
||||
|| expansionCacheRange?.endMs !== range?.endMs
|
||||
) {
|
||||
expansionCache.clear()
|
||||
expansionCacheRange = range
|
||||
}
|
||||
|
||||
const present = new Set<string>()
|
||||
const byDay: InstancesByDay = new Map()
|
||||
|
||||
for (const entity of operationsStore.filteredEvents) {
|
||||
const identifier = String(entity.identifier)
|
||||
present.add(identifier)
|
||||
|
||||
let entry = expansionCache.get(identifier)
|
||||
if (!entry || entry.entity !== entity) {
|
||||
entry = { entity, instances: expandEntityInstances(entity, range ?? undefined) }
|
||||
expansionCache.set(identifier, entry)
|
||||
}
|
||||
for (const instance of entry.instances) appendToDayBuckets(byDay, instance)
|
||||
}
|
||||
|
||||
for (const identifier of expansionCache.keys()) {
|
||||
if (!present.has(identifier)) expansionCache.delete(identifier)
|
||||
}
|
||||
|
||||
for (const bucket of byDay.values()) sortInstances(bucket)
|
||||
|
||||
return byDay
|
||||
})
|
||||
|
||||
function instancesForDay(date: Date | number): CalendarInstance[] {
|
||||
const dayKey = startOfLocalDay(new Date(date)).getTime()
|
||||
return instancesByDay.value.get(dayKey) ?? []
|
||||
}
|
||||
|
||||
function instancesForRange(range: VisibleDateRange): CalendarInstance[] {
|
||||
if (range.endMs <= range.startMs) return []
|
||||
|
||||
const matches = new Map<string, CalendarInstance>()
|
||||
let day = startOfLocalDay(new Date(range.startMs))
|
||||
|
||||
while (day.getTime() < range.endMs) {
|
||||
for (const instance of instancesByDay.value.get(day.getTime()) ?? []) {
|
||||
if (instance.startMs < range.endMs && instance.endMs > range.startMs) {
|
||||
matches.set(instance.key, instance)
|
||||
}
|
||||
}
|
||||
day = startOfLocalDay(shiftLocalDays(day, 1))
|
||||
}
|
||||
|
||||
const instances = Array.from(matches.values())
|
||||
sortInstances(instances)
|
||||
return instances
|
||||
}
|
||||
|
||||
function setVisibleRange(range: VisibleDateRange) {
|
||||
if (
|
||||
visibleRange.value?.startMs === range.startMs
|
||||
&& visibleRange.value.endMs === range.endMs
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
visibleRange.value = { ...range }
|
||||
}
|
||||
|
||||
return {
|
||||
visibleRange,
|
||||
setVisibleRange,
|
||||
instancesForDay,
|
||||
instancesForRange,
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||
import { EntityObject } from '@ChronoManager/models/entity'
|
||||
@@ -9,8 +9,6 @@ import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
||||
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
|
||||
|
||||
@@ -21,7 +19,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
||||
const importStore = useImportStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const visibleRange = shallowRef<VisibleDateRange | null>(null)
|
||||
|
||||
const collections = computed(() => collectionsStore.collections)
|
||||
const entities = computed(() => entitiesStore.entities)
|
||||
@@ -45,24 +42,6 @@ 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,
|
||||
visibleRange.value ?? undefined,
|
||||
))
|
||||
const visibleOccurrences = computed(() => visibleRange.value
|
||||
? occurrencesForRange(occurrenceIndex.value, visibleRange.value)
|
||||
: [])
|
||||
|
||||
function setVisibleRange(range: VisibleDateRange) {
|
||||
if (
|
||||
visibleRange.value?.startMs === range.startMs
|
||||
&& visibleRange.value.endMs === range.endMs
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
visibleRange.value = { ...range }
|
||||
}
|
||||
|
||||
function prepareEntityProperties(entity: EntityObject): ChronoEntityProperties {
|
||||
const properties = entity.properties as ChronoEntityProperties
|
||||
@@ -175,7 +154,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
||||
|
||||
return {
|
||||
loading,
|
||||
visibleRange,
|
||||
collections,
|
||||
entities,
|
||||
calendars,
|
||||
@@ -184,9 +162,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
||||
tasks,
|
||||
filteredEvents,
|
||||
filteredTasks,
|
||||
occurrenceIndex,
|
||||
visibleOccurrences,
|
||||
setVisibleRange,
|
||||
saveEntity,
|
||||
deleteEntity,
|
||||
toggleCalendarVisibility,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||
|
||||
export interface CalendarOccurrence {
|
||||
export interface CalendarInstance {
|
||||
key: string
|
||||
entity: EntityObject
|
||||
occurrenceId: string | null
|
||||
instanceId: string | null
|
||||
startMs: number
|
||||
endMs: number
|
||||
dayStartMs: number
|
||||
@@ -13,9 +13,3 @@ export interface CalendarOccurrence {
|
||||
label: string | null
|
||||
color: string | null
|
||||
}
|
||||
|
||||
export interface CalendarOccurrenceIndex {
|
||||
byDay: Map<number, CalendarOccurrence[]>
|
||||
byEntity: Map<string, CalendarOccurrence[]>
|
||||
sorted: CalendarOccurrence[]
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* Calendar Helper Utilities
|
||||
* Shared logic for multi-day event handling across calendar views
|
||||
*/
|
||||
|
||||
import type { CalendarOccurrence } from '@/types/occurrence'
|
||||
|
||||
export interface MultiDaySegment {
|
||||
occurrence: CalendarOccurrence;
|
||||
startCol: number;
|
||||
span: number;
|
||||
lane: number;
|
||||
isStart: boolean;
|
||||
isEnd: boolean;
|
||||
}
|
||||
|
||||
export interface LaneAssignment {
|
||||
segments: MultiDaySegment[];
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of day (midnight) for a given date
|
||||
*/
|
||||
export function startOfDay(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference in days between two dates
|
||||
*/
|
||||
export function daysDiff(a: Date, b: Date): number {
|
||||
const msPerDay = 24 * 60 * 60 * 1000;
|
||||
return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / msPerDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event spans multiple days
|
||||
*/
|
||||
export function isMultiDay(entity: any): boolean {
|
||||
if (!entity.properties?.startsOn) return false;
|
||||
const start = startOfDay(new Date(entity.properties.startsOn));
|
||||
const end = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : start;
|
||||
return daysDiff(start, end) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is an all-day event (no specific time, or spans full day)
|
||||
*/
|
||||
export function isAllDay(entity: any): boolean {
|
||||
if (!entity.properties?.startsOn) return false;
|
||||
|
||||
// Explicit all-day flag
|
||||
if (entity.properties?.timeless === true) return true;
|
||||
|
||||
// Multi-day events are treated as all-day in the days view
|
||||
if (isMultiDay(entity)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event overlaps with a date range
|
||||
*/
|
||||
export function eventOverlapsRange(entity: any, rangeStart: Date, rangeEnd: Date): boolean {
|
||||
if (!entity.properties?.startsOn) return false;
|
||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
||||
const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
|
||||
return eventStart <= rangeEnd && eventEnd >= rangeStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event occurs on a specific date
|
||||
*/
|
||||
export function eventOnDate(entity: any, date: Date): boolean {
|
||||
if (!entity.properties?.startsOn) return false;
|
||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
||||
const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
|
||||
const targetDate = startOfDay(date);
|
||||
return eventStart <= targetDate && eventEnd >= targetDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multi-day event segments for a given date range with lane assignments
|
||||
* Used for rendering spanning events across columns
|
||||
*/
|
||||
export function getMultiDaySegments(
|
||||
occurrences: CalendarOccurrence[],
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
columnCount: number,
|
||||
getColumnIndex: (date: Date) => number
|
||||
): LaneAssignment {
|
||||
const segments: MultiDaySegment[] = [];
|
||||
|
||||
// Filter multi-day/all-day events that overlap this range
|
||||
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) => {
|
||||
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 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;
|
||||
const segEnd = eventEnd > rangeEnd ? rangeEnd : eventEnd;
|
||||
|
||||
const startCol = getColumnIndex(segStart);
|
||||
const endCol = getColumnIndex(segEnd);
|
||||
|
||||
// Skip if outside visible columns
|
||||
if (startCol < 0 || startCol >= columnCount) continue;
|
||||
|
||||
const span = Math.min(endCol, columnCount - 1) - startCol + 1;
|
||||
|
||||
// Find available lane
|
||||
let lane = 0;
|
||||
while (true) {
|
||||
if (!lanes[lane]) {
|
||||
lanes[lane] = new Array(columnCount).fill(false);
|
||||
}
|
||||
|
||||
let isFree = true;
|
||||
for (let d = startCol; d < startCol + span; d++) {
|
||||
if (lanes[lane][d]) {
|
||||
isFree = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
for (let d = startCol; d < startCol + span; d++) {
|
||||
lanes[lane][d] = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
lane++;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
occurrence,
|
||||
startCol,
|
||||
span,
|
||||
lane,
|
||||
isStart: eventStart >= rangeStart,
|
||||
isEnd: eventEnd <= rangeEnd,
|
||||
});
|
||||
}
|
||||
|
||||
return { segments, laneCount: lanes.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timed (non-all-day) events for a specific date
|
||||
*/
|
||||
export function getTimedEventsForDate(events: any[], date: Date): any[] {
|
||||
return events.filter(entity => {
|
||||
if (!entity.properties?.startsOn) return false;
|
||||
if (isAllDay(entity)) return false;
|
||||
const eventDate = startOfDay(new Date(entity.properties.startsOn));
|
||||
return eventDate.toDateString() === date.toDateString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single-day events for a specific date (used in month view)
|
||||
*/
|
||||
export function getSingleDayEventsForDate(events: any[], date: Date): any[] {
|
||||
return events.filter(entity => {
|
||||
if (!entity.properties?.startsOn) return false;
|
||||
if (isMultiDay(entity)) return false;
|
||||
const eventDate = startOfDay(new Date(entity.properties.startsOn));
|
||||
return eventDate.toDateString() === date.toDateString();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { VisibleDateRange } from '../types'
|
||||
|
||||
export function parseCalendarDate(value: string | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
|
||||
const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
|
||||
if (!parts) return null
|
||||
|
||||
const year = Number(parts[1])
|
||||
const month = Number(parts[2]) - 1
|
||||
const day = Number(parts[3])
|
||||
const date = new Date(year, month, day)
|
||||
|
||||
return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day
|
||||
? date
|
||||
: null
|
||||
}
|
||||
|
||||
export function startOfLocalDay(date: Date): Date {
|
||||
const start = new Date(date)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Multi-Day Lane Layout
|
||||
* Assigns multi-day/all-day instances to horizontal lanes so they render
|
||||
* as spanning bars across day columns without overlapping.
|
||||
*/
|
||||
|
||||
import type { CalendarInstance } from '@/types/instance'
|
||||
import { startOfLocalDay } from './date'
|
||||
|
||||
export interface MultiDaySegment {
|
||||
instance: CalendarInstance;
|
||||
startCol: number;
|
||||
span: number;
|
||||
lane: number;
|
||||
isStart: boolean;
|
||||
isEnd: boolean;
|
||||
}
|
||||
|
||||
export interface LaneAssignment {
|
||||
segments: MultiDaySegment[];
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
export function layoutMultiDayLanes(
|
||||
instances: CalendarInstance[],
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
columnCount: number,
|
||||
getColumnIndex: (date: Date) => number
|
||||
): LaneAssignment {
|
||||
const segments: MultiDaySegment[] = [];
|
||||
|
||||
// Filter multi-day/all-day events that overlap this range
|
||||
const rangeStartMs = startOfLocalDay(rangeStart).getTime();
|
||||
const rangeEndExclusive = startOfLocalDay(new Date(rangeEnd));
|
||||
rangeEndExclusive.setDate(rangeEndExclusive.getDate() + 1);
|
||||
|
||||
const multiDayEvents = instances.filter(instance => {
|
||||
return instance.timeless
|
||||
&& instance.startMs < rangeEndExclusive.getTime()
|
||||
&& instance.endMs > rangeStartMs;
|
||||
});
|
||||
|
||||
// Sort: longer events first, then by start date
|
||||
multiDayEvents.sort((a, b) => {
|
||||
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 instance of multiDayEvents) {
|
||||
const eventStart = startOfLocalDay(new Date(instance.startMs));
|
||||
const eventEnd = startOfLocalDay(new Date(instance.endMs - 1));
|
||||
|
||||
// Clamp to visible range
|
||||
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
||||
const segEnd = eventEnd > rangeEnd ? rangeEnd : eventEnd;
|
||||
|
||||
const startCol = getColumnIndex(segStart);
|
||||
const endCol = getColumnIndex(segEnd);
|
||||
|
||||
// Skip if outside visible columns
|
||||
if (startCol < 0 || startCol >= columnCount) continue;
|
||||
|
||||
const span = Math.min(endCol, columnCount - 1) - startCol + 1;
|
||||
|
||||
// Find available lane
|
||||
let lane = 0;
|
||||
while (true) {
|
||||
if (!lanes[lane]) {
|
||||
lanes[lane] = new Array(columnCount).fill(false);
|
||||
}
|
||||
|
||||
let isFree = true;
|
||||
for (let d = startCol; d < startCol + span; d++) {
|
||||
if (lanes[lane][d]) {
|
||||
isFree = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
for (let d = startCol; d < startCol + span; d++) {
|
||||
lanes[lane][d] = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
lane++;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
instance,
|
||||
startCol,
|
||||
span,
|
||||
lane,
|
||||
isStart: eventStart >= rangeStart,
|
||||
isEnd: eventEnd <= rangeEnd,
|
||||
});
|
||||
}
|
||||
|
||||
return { segments, laneCount: lanes.length };
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||
import type {
|
||||
CalendarOccurrence,
|
||||
CalendarOccurrenceIndex,
|
||||
} from '../types/occurrence'
|
||||
import type { VisibleDateRange } from '../types'
|
||||
import { shiftLocalDays, startOfLocalDay } from './dateRanges'
|
||||
import { expandEntityOccurrences } from './recurrence'
|
||||
|
||||
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[],
|
||||
range?: VisibleDateRange,
|
||||
): CalendarOccurrenceIndex {
|
||||
const index: CalendarOccurrenceIndex = {
|
||||
byDay: new Map(),
|
||||
byEntity: new Map(),
|
||||
sorted: [],
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const entityOccurrences = expandEntityOccurrences(entity, range)
|
||||
if (entityOccurrences.length === 0) continue
|
||||
|
||||
index.byEntity.set(String(entity.identifier), entityOccurrences)
|
||||
for (const occurrence of entityOccurrences) {
|
||||
index.sorted.push(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
|
||||
}
|
||||
+89
-47
@@ -1,9 +1,9 @@
|
||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||
import type { EventObject } from '@ChronoManager/models/event'
|
||||
import type { EventMutationObject } from '@ChronoManager/models/event-mutation'
|
||||
import type { CalendarOccurrence } from '../types/occurrence'
|
||||
import type { CalendarInstance } from '../types/instance'
|
||||
import type { VisibleDateRange } from '../types'
|
||||
import { shiftLocalDays, startOfLocalDay } from './dateRanges'
|
||||
import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date'
|
||||
|
||||
const MINIMUM_DURATION_MS = 1
|
||||
|
||||
@@ -13,6 +13,14 @@ function timestamp(value: string | null | undefined): number | null {
|
||||
return Number.isFinite(result) ? result : null
|
||||
}
|
||||
|
||||
function floatingDateTimestamp(value: string | null | undefined): number | null {
|
||||
return parseCalendarDate(value)?.getTime() ?? null
|
||||
}
|
||||
|
||||
function eventTimestamp(value: string | null | undefined, timeless: boolean): number | null {
|
||||
return timeless ? floatingDateTimestamp(value) : timestamp(value)
|
||||
}
|
||||
|
||||
function conclusionTimestamp(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
|
||||
@@ -36,63 +44,79 @@ function overlapsRange(startMs: number, endMs: number, range?: VisibleDateRange)
|
||||
return !range || (startMs < range.endMs && endMs > range.startMs)
|
||||
}
|
||||
|
||||
function createOccurrence(
|
||||
function createInstance(
|
||||
entity: EntityObject,
|
||||
sourceStartMs: number,
|
||||
effectiveStartMs: number,
|
||||
effectiveEndMs: number,
|
||||
recurring: boolean,
|
||||
timeless: boolean,
|
||||
mutation?: EventMutationObject,
|
||||
): CalendarOccurrence {
|
||||
): CalendarInstance {
|
||||
const event = entity.properties as EventObject
|
||||
const endMs = effectiveEndMs > effectiveStartMs
|
||||
? effectiveEndMs
|
||||
: effectiveStartMs + MINIMUM_DURATION_MS
|
||||
const dayStartMs = startOfLocalDay(new Date(effectiveStartMs)).getTime()
|
||||
const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).getTime()
|
||||
const occurrenceId = recurring ? new Date(sourceStartMs).toISOString() : null
|
||||
const instanceId = recurring ? new Date(sourceStartMs).toISOString() : null
|
||||
|
||||
return {
|
||||
key: `${String(entity.identifier)}:${occurrenceId ?? 'master'}`,
|
||||
key: `${String(entity.identifier)}:${instanceId ?? 'master'}`,
|
||||
entity,
|
||||
occurrenceId,
|
||||
instanceId,
|
||||
startMs: effectiveStartMs,
|
||||
endMs,
|
||||
dayStartMs,
|
||||
durationMs: endMs - effectiveStartMs,
|
||||
timeless: (mutation?.timeless ?? event.timeless === true) || lastOccupiedDayMs !== dayStartMs,
|
||||
timeless: timeless || lastOccupiedDayMs !== dayStartMs,
|
||||
multiDay: lastOccupiedDayMs !== dayStartMs,
|
||||
label: mutation?.label ?? event.label,
|
||||
color: mutation?.color ?? event.color,
|
||||
}
|
||||
}
|
||||
|
||||
function mutationMap(event: EventObject): Map<number, EventMutationObject> {
|
||||
function mutationMap(event: EventObject, timeless: boolean): Map<number, EventMutationObject> {
|
||||
const mutations = new Map<number, EventMutationObject>()
|
||||
|
||||
for (const [key, mutation] of Object.entries(event.mutations ?? {})) {
|
||||
const mutationId = timestamp(mutation.mutationId) ?? timestamp(key)
|
||||
const mutationId = eventTimestamp(mutation.mutationId, timeless)
|
||||
?? eventTimestamp(key, timeless)
|
||||
if (mutationId !== null) mutations.set(mutationId, mutation)
|
||||
}
|
||||
|
||||
return mutations
|
||||
}
|
||||
|
||||
function materializeOccurrence(
|
||||
function materializeInstance(
|
||||
entity: EntityObject,
|
||||
sourceStartMs: number,
|
||||
durationMs: number,
|
||||
durationDays: number | null,
|
||||
recurring: boolean,
|
||||
mutation: EventMutationObject | undefined,
|
||||
range?: VisibleDateRange,
|
||||
): CalendarOccurrence | null {
|
||||
): CalendarInstance | null {
|
||||
if (mutation?.mutationExclusion === true) return null
|
||||
|
||||
const effectiveStartMs = timestamp(mutation?.startsOn) ?? sourceStartMs
|
||||
const effectiveEndMs = timestamp(mutation?.endsOn) ?? effectiveStartMs + durationMs
|
||||
const event = entity.properties as EventObject
|
||||
const timeless = mutation?.timeless ?? event.timeless === true
|
||||
const effectiveStartMs = eventTimestamp(mutation?.startsOn, timeless) ?? sourceStartMs
|
||||
const defaultEndMs = timeless && durationDays !== null
|
||||
? shiftLocalDays(new Date(effectiveStartMs), durationDays).getTime()
|
||||
: effectiveStartMs + durationMs
|
||||
const effectiveEndMs = eventTimestamp(mutation?.endsOn, timeless) ?? defaultEndMs
|
||||
if (!overlapsRange(effectiveStartMs, effectiveEndMs, range)) return null
|
||||
|
||||
return createOccurrence(entity, sourceStartMs, effectiveStartMs, effectiveEndMs, recurring, mutation)
|
||||
return createInstance(
|
||||
entity,
|
||||
sourceStartMs,
|
||||
effectiveStartMs,
|
||||
effectiveEndMs,
|
||||
recurring,
|
||||
timeless,
|
||||
mutation,
|
||||
)
|
||||
}
|
||||
|
||||
function localDayNumber(date: Date): number {
|
||||
@@ -131,9 +155,10 @@ function expandDaily(
|
||||
entity: EntityObject,
|
||||
start: Date,
|
||||
durationMs: number,
|
||||
durationDays: number | null,
|
||||
range: VisibleDateRange,
|
||||
mutations: Map<number, EventMutationObject>,
|
||||
): CalendarOccurrence[] {
|
||||
): CalendarInstance[] {
|
||||
const pattern = (entity.properties as EventObject).pattern!
|
||||
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
||||
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
||||
@@ -147,7 +172,7 @@ function expandDaily(
|
||||
? dailyStartIndex(start, interval, durationMs, range)
|
||||
: 0
|
||||
let occurrenceCount = weekdays ? 0 : step
|
||||
const occurrences: CalendarOccurrence[] = []
|
||||
const instances: CalendarInstance[] = []
|
||||
|
||||
while (true) {
|
||||
const candidate = shiftLocalDays(start, step * interval)
|
||||
@@ -159,30 +184,32 @@ function expandDaily(
|
||||
occurrenceCount += 1
|
||||
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break
|
||||
|
||||
const occurrence = materializeOccurrence(
|
||||
const instance = materializeInstance(
|
||||
entity,
|
||||
candidateMs,
|
||||
durationMs,
|
||||
durationDays,
|
||||
true,
|
||||
mutations.get(candidateMs),
|
||||
range,
|
||||
)
|
||||
if (occurrence) occurrences.push(occurrence)
|
||||
if (instance) instances.push(instance)
|
||||
}
|
||||
|
||||
step += 1
|
||||
}
|
||||
|
||||
return occurrences
|
||||
return instances
|
||||
}
|
||||
|
||||
function expandWeekly(
|
||||
entity: EntityObject,
|
||||
start: Date,
|
||||
durationMs: number,
|
||||
durationDays: number | null,
|
||||
range: VisibleDateRange,
|
||||
mutations: Map<number, EventMutationObject>,
|
||||
): CalendarOccurrence[] {
|
||||
): CalendarInstance[] {
|
||||
const pattern = (entity.properties as EventObject).pattern!
|
||||
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
||||
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
||||
@@ -195,7 +222,7 @@ function expandWeekly(
|
||||
? weeklyStartIndex(weekStart, interval, durationMs, range)
|
||||
: 0
|
||||
let occurrenceCount = maximumOccurrences === null ? week * weekdays.length : 0
|
||||
const occurrences: CalendarOccurrence[] = []
|
||||
const instances: CalendarInstance[] = []
|
||||
|
||||
while (true) {
|
||||
const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7)
|
||||
@@ -205,79 +232,94 @@ function expandWeekly(
|
||||
const candidate = shiftLocalDays(intervalWeekStart, weekday - 1)
|
||||
const candidateMs = candidate.getTime()
|
||||
if (candidateMs < start.getTime()) continue
|
||||
if (candidateMs >= range.endMs) return occurrences
|
||||
if (concludesMs !== null && candidateMs > concludesMs) return occurrences
|
||||
if (candidateMs >= range.endMs) return instances
|
||||
if (concludesMs !== null && candidateMs > concludesMs) return instances
|
||||
|
||||
occurrenceCount += 1
|
||||
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return occurrences
|
||||
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return instances
|
||||
|
||||
const occurrence = materializeOccurrence(
|
||||
const instance = materializeInstance(
|
||||
entity,
|
||||
candidateMs,
|
||||
durationMs,
|
||||
durationDays,
|
||||
true,
|
||||
mutations.get(candidateMs),
|
||||
range,
|
||||
)
|
||||
if (occurrence) occurrences.push(occurrence)
|
||||
if (instance) instances.push(instance)
|
||||
}
|
||||
|
||||
week += 1
|
||||
}
|
||||
|
||||
return occurrences
|
||||
return instances
|
||||
}
|
||||
|
||||
function appendMovedMutations(
|
||||
occurrences: CalendarOccurrence[],
|
||||
instances: CalendarInstance[],
|
||||
entity: EntityObject,
|
||||
durationMs: number,
|
||||
durationDays: number | null,
|
||||
range: VisibleDateRange,
|
||||
mutations: Map<number, EventMutationObject>,
|
||||
) {
|
||||
const present = new Set(occurrences.map(occurrence => occurrence.key))
|
||||
const present = new Set(instances.map(instance => instance.key))
|
||||
|
||||
for (const [sourceStartMs, mutation] of mutations) {
|
||||
const occurrence = materializeOccurrence(
|
||||
const instance = materializeInstance(
|
||||
entity,
|
||||
sourceStartMs,
|
||||
durationMs,
|
||||
durationDays,
|
||||
true,
|
||||
mutation,
|
||||
range,
|
||||
)
|
||||
if (occurrence && !present.has(occurrence.key)) {
|
||||
occurrences.push(occurrence)
|
||||
present.add(occurrence.key)
|
||||
if (instance && !present.has(instance.key)) {
|
||||
instances.push(instance)
|
||||
present.add(instance.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function expandEntityOccurrences(
|
||||
export function expandEntityInstances(
|
||||
entity: EntityObject,
|
||||
range?: VisibleDateRange,
|
||||
): CalendarOccurrence[] {
|
||||
): CalendarInstance[] {
|
||||
const event = entity.properties as EventObject
|
||||
const startMs = timestamp(event.startsOn)
|
||||
const timeless = event.timeless === true
|
||||
const startMs = eventTimestamp(event.startsOn, timeless)
|
||||
if (startMs === null) return []
|
||||
|
||||
const parsedEndMs = timestamp(event.endsOn)
|
||||
const parsedEndMs = eventTimestamp(event.endsOn, timeless)
|
||||
const durationMs = parsedEndMs !== null && parsedEndMs > startMs
|
||||
? parsedEndMs - startMs
|
||||
: MINIMUM_DURATION_MS
|
||||
const durationDays = timeless && parsedEndMs !== null && parsedEndMs > startMs
|
||||
? Math.max(1, localDayNumber(new Date(parsedEndMs)) - localDayNumber(new Date(startMs)))
|
||||
: null
|
||||
const pattern = event.pattern
|
||||
|
||||
if (!pattern || !range || (pattern.precision !== 'daily' && pattern.precision !== 'weekly')) {
|
||||
const occurrence = materializeOccurrence(entity, startMs, durationMs, false, undefined, range)
|
||||
return occurrence ? [occurrence] : []
|
||||
const instance = materializeInstance(
|
||||
entity,
|
||||
startMs,
|
||||
durationMs,
|
||||
durationDays,
|
||||
false,
|
||||
undefined,
|
||||
range,
|
||||
)
|
||||
return instance ? [instance] : []
|
||||
}
|
||||
|
||||
const mutations = mutationMap(event)
|
||||
const occurrences = pattern.precision === 'daily'
|
||||
? expandDaily(entity, new Date(startMs), durationMs, range, mutations)
|
||||
: expandWeekly(entity, new Date(startMs), durationMs, range, mutations)
|
||||
const mutations = mutationMap(event, timeless)
|
||||
const instances = pattern.precision === 'daily'
|
||||
? expandDaily(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
||||
: expandWeekly(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
||||
|
||||
appendMovedMutations(occurrences, entity, durationMs, range, mutations)
|
||||
occurrences.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
|
||||
return occurrences
|
||||
appendMovedMutations(instances, entity, durationMs, durationDays, range, mutations)
|
||||
instances.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
|
||||
return instances
|
||||
}
|
||||
|
||||
@@ -66,6 +66,21 @@ describe('occurrence index', () => {
|
||||
expect(occurrencesForDay(index, new Date(2026, 5, 21))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats UTC timestamps on timeless events as floating calendar dates', () => {
|
||||
const allDay = eventEntity(
|
||||
'utc-all-day',
|
||||
'2025-03-27T00:00:00+00:00',
|
||||
'2025-03-28T00:00:00+00:00',
|
||||
true,
|
||||
)
|
||||
const index = buildOccurrenceIndex([allDay])
|
||||
|
||||
expect(occurrencesForDay(index, new Date(2025, 2, 26))).toHaveLength(0)
|
||||
expect(occurrencesForDay(index, new Date(2025, 2, 27))).toHaveLength(1)
|
||||
expect(occurrencesForDay(index, new Date(2025, 2, 28))).toHaveLength(0)
|
||||
expect(new Date(index.sorted[0].startMs).getHours()).toBe(0)
|
||||
})
|
||||
|
||||
it('retrieves overlapping occurrences without duplicates', () => {
|
||||
const spanning = eventEntity(
|
||||
'spanning',
|
||||
|
||||
@@ -87,6 +87,32 @@ describe('daily recurrence', () => {
|
||||
|
||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([9, 9, 9])
|
||||
})
|
||||
|
||||
it('preserves exclusive calendar-day duration for recurring timeless events', () => {
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'daily',
|
||||
interval: 1,
|
||||
}, {}, '2026-03-07T00:00:00+00:00')
|
||||
const event = entity.properties as unknown as {
|
||||
endsOn: string
|
||||
timeless: boolean
|
||||
}
|
||||
event.endsOn = '2026-03-08T00:00:00+00:00'
|
||||
event.timeless = true
|
||||
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 2, 7), 3),
|
||||
)
|
||||
|
||||
expect(occurrences).toHaveLength(3)
|
||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([0, 0, 0])
|
||||
expect(occurrences.map(occurrence => {
|
||||
const end = new Date(occurrence.endMs)
|
||||
return [end.getDate(), end.getHours()]
|
||||
})).toEqual([[8, 0], [9, 0], [10, 0]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('weekly recurrence', () => {
|
||||
|
||||
Reference in New Issue
Block a user