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-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="(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-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="occurrence in dayOccurrences"
|
v-for="instance in dayInstances"
|
||||||
:key="occurrence.key"
|
:key="instance.key"
|
||||||
@click="$emit('event-click', occurrence.entity)"
|
@click="$emit('event-click', instance.entity)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon :color="getEventColor(occurrence)">mdi-circle</v-icon>
|
<v-icon :color="getEventColor(instance)">mdi-circle</v-icon>
|
||||||
</template>
|
</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>
|
<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-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,20 +52,21 @@ import { ref, computed, watch } from 'vue';
|
|||||||
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
|
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
|
||||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
|
import { daysVisibleRange, shiftLocalDays } from '@/utils/date';
|
||||||
import { occurrencesForRange } from '@/utils/occurrenceIndex';
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
|
|
||||||
type CalendarEntity = EntityObject;
|
type CalendarEntity = EntityObject;
|
||||||
type CalendarCollection = CollectionObject;
|
type CalendarCollection = CollectionObject;
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
occurrenceIndex: CalendarOccurrenceIndex;
|
|
||||||
calendars: CalendarCollection[];
|
calendars: CalendarCollection[];
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
initialSpan?: AgendaViewSpan;
|
initialSpan?: AgendaViewSpan;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const instancesStore = useChronoInstancesStore();
|
||||||
|
|
||||||
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
|
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -127,28 +128,28 @@ const dateRangeLabel = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupedEvents = computed(() => {
|
const groupedEvents = computed(() => {
|
||||||
const grouped: Record<string, CalendarOccurrence[]> = {};
|
const grouped: Record<string, CalendarInstance[]> = {};
|
||||||
const { start, end } = dateRange.value;
|
const { start, end } = dateRange.value;
|
||||||
|
|
||||||
const occurrences = occurrencesForRange(props.occurrenceIndex, {
|
const instances = instancesStore.instancesForRange({
|
||||||
startMs: start.getTime(),
|
startMs: start.getTime(),
|
||||||
endMs: end.getTime(),
|
endMs: end.getTime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
occurrences.forEach(occurrence => {
|
instances.forEach(instance => {
|
||||||
const dateKey = new Date(occurrence.startMs).toDateString();
|
const dateKey = new Date(instance.startMs).toDateString();
|
||||||
if (!grouped[dateKey]) {
|
if (!grouped[dateKey]) {
|
||||||
grouped[dateKey] = [];
|
grouped[dateKey] = [];
|
||||||
}
|
}
|
||||||
grouped[dateKey].push(occurrence);
|
grouped[dateKey].push(instance);
|
||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
function getEventColor(instance: CalendarInstance): string {
|
||||||
const entity = occurrence.entity;
|
const entity = instance.entity;
|
||||||
if (occurrence.color) return occurrence.color;
|
if (instance.color) return instance.color;
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
||||||
return calendar?.properties?.color || '#1976D2';
|
return calendar?.properties?.color || '#1976D2';
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@ function formatTime(date: Date): string {
|
|||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOccurrenceTime(timestamp: number): string {
|
function formatInstanceTime(timestamp: number): string {
|
||||||
return formatTime(new Date(timestamp));
|
return formatTime(new Date(timestamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<MonthView
|
<MonthView
|
||||||
v-if="view === 'month'"
|
v-if="view === 'month'"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:occurrence-index="occurrenceIndex"
|
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
@event-click="$emit('event-click', $event)"
|
@event-click="$emit('event-click', $event)"
|
||||||
@date-click="$emit('date-click', $event)"
|
@date-click="$emit('date-click', $event)"
|
||||||
@@ -20,7 +19,6 @@
|
|||||||
<DaysView
|
<DaysView
|
||||||
v-else-if="view === 'days'"
|
v-else-if="view === 'days'"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:occurrence-index="occurrenceIndex"
|
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
:initial-span="initialDaysSpan"
|
:initial-span="initialDaysSpan"
|
||||||
@event-click="$emit('event-click', $event)"
|
@event-click="$emit('event-click', $event)"
|
||||||
@@ -32,7 +30,6 @@
|
|||||||
/>
|
/>
|
||||||
<AgendaView
|
<AgendaView
|
||||||
v-else-if="view === 'agenda'"
|
v-else-if="view === 'agenda'"
|
||||||
:occurrence-index="occurrenceIndex"
|
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:initial-span="initialAgendaViewSpan"
|
: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 { EntityObject } from '@ChronoManager/models/entity';
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
import type { VisibleDateRange } from '@/types';
|
import type { VisibleDateRange } from '@/types';
|
||||||
import type { CalendarOccurrenceIndex } from '@/types/occurrence';
|
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/date.ts';
|
||||||
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/dateRanges';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
view: 'days' | 'month' | 'agenda';
|
view: 'days' | 'month' | 'agenda';
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
occurrenceIndex: CalendarOccurrenceIndex;
|
|
||||||
calendars: CollectionObject[];
|
calendars: CollectionObject[];
|
||||||
initialDaysSpan?: DaysViewSpan;
|
initialDaysSpan?: DaysViewSpan;
|
||||||
initialAgendaViewSpan?: AgendaViewSpan;
|
initialAgendaViewSpan?: AgendaViewSpan;
|
||||||
|
|||||||
+35
-38
@@ -45,18 +45,18 @@
|
|||||||
<div class="all-day-events-overlay">
|
<div class="all-day-events-overlay">
|
||||||
<div
|
<div
|
||||||
v-for="segment in allDaySegments.segments"
|
v-for="segment in allDaySegments.segments"
|
||||||
:key="segment.occurrence.key"
|
:key="segment.instance.key"
|
||||||
class="all-day-event"
|
class="all-day-event"
|
||||||
:class="{
|
:class="{
|
||||||
'is-start': segment.isStart,
|
'is-start': segment.isStart,
|
||||||
'is-end': segment.isEnd,
|
'is-end': segment.isEnd,
|
||||||
}"
|
}"
|
||||||
:style="getAllDayEventStyle(segment)"
|
:style="getAllDayEventStyle(segment)"
|
||||||
@click="emit('event-click', segment.occurrence.entity)"
|
@click="emit('event-click', segment.instance.entity)"
|
||||||
@mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
|
@mouseenter="emit('event-hover', { event: $event, entity: segment.instance.entity })"
|
||||||
@mouseleave="emit('event-hover-end')"
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,22 +73,22 @@
|
|||||||
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
|
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
|
||||||
<div class="day-events" @click="handleDayClick($event, day)">
|
<div class="day-events" @click="handleDayClick($event, day)">
|
||||||
<div
|
<div
|
||||||
v-for="occurrence in getTimedEvents(day)"
|
v-for="instance in getTimedEvents(day)"
|
||||||
:key="occurrence.key"
|
:key="instance.key"
|
||||||
class="day-event"
|
class="day-event"
|
||||||
:style="getEventStyle(occurrence)"
|
:style="getEventStyle(instance)"
|
||||||
@click.stop="emit('event-click', occurrence.entity)"
|
@click.stop="emit('event-click', instance.entity)"
|
||||||
@mouseenter="emit('event-hover', { event: $event, entity: occurrence.entity })"
|
@mouseenter="emit('event-hover', { event: $event, entity: instance.entity })"
|
||||||
@mouseleave="emit('event-hover-end')"
|
@mouseleave="emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
<template v-if="daysCount <= 3">
|
<template v-if="daysCount <= 3">
|
||||||
<div class="event-time">
|
<div class="event-time">
|
||||||
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
|
{{ formatInstanceTime(instance.startMs) }} - {{ formatInstanceTime(instance.endMs) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-title">{{ occurrence.label || 'Untitled' }}</div>
|
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="event-title-compact">{{ occurrence.label || 'Untitled' }}</div>
|
<div class="event-title-compact">{{ instance.label || 'Untitled' }}</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,17 +101,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import {
|
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
||||||
startOfDay,
|
import { shiftLocalDays, startOfLocalDay } from '@/utils/date';
|
||||||
getMultiDaySegments,
|
|
||||||
type MultiDaySegment,
|
|
||||||
} from '@/utils/calendarHelpers';
|
|
||||||
import { shiftLocalDays } from '@/utils/dateRanges';
|
|
||||||
import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
|
import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
|
||||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
|
|
||||||
const ALL_DAY_EVENT_HEIGHT = 24;
|
const ALL_DAY_EVENT_HEIGHT = 24;
|
||||||
|
|
||||||
@@ -120,11 +116,12 @@ type CalendarCollection = CollectionObject;
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
occurrenceIndex: CalendarOccurrenceIndex;
|
|
||||||
calendars: CalendarCollection[];
|
calendars: CalendarCollection[];
|
||||||
initialSpan?: DaysViewSpan;
|
initialSpan?: DaysViewSpan;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const instancesStore = useChronoInstancesStore();
|
||||||
|
|
||||||
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
|
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
|
||||||
const daysCount = computed(() => spanToDays(selectedSpan.value));
|
const daysCount = computed(() => spanToDays(selectedSpan.value));
|
||||||
|
|
||||||
@@ -197,20 +194,20 @@ const allDaySegments = computed(() => {
|
|||||||
return { segments: [], laneCount: 0 };
|
return { segments: [], laneCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeStart = startOfDay(visibleDates.value[0]);
|
const rangeStart = startOfLocalDay(visibleDates.value[0]);
|
||||||
const rangeEnd = startOfDay(visibleDates.value[visibleDates.value.length - 1]);
|
const rangeEnd = startOfLocalDay(visibleDates.value[visibleDates.value.length - 1]);
|
||||||
|
|
||||||
const getColumnIndex = (date: Date): number => {
|
const getColumnIndex = (date: Date): number => {
|
||||||
const targetDay = startOfDay(date);
|
const targetDay = startOfLocalDay(date);
|
||||||
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
|
return visibleDates.value.findIndex(d => startOfLocalDay(d).getTime() === targetDay.getTime());
|
||||||
};
|
};
|
||||||
|
|
||||||
const occurrences = occurrencesForRange(props.occurrenceIndex, {
|
const instances = instancesStore.instancesForRange({
|
||||||
startMs: rangeStart.getTime(),
|
startMs: rangeStart.getTime(),
|
||||||
endMs: shiftLocalDays(rangeEnd, 1).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 {
|
function isToday(date: Date): boolean {
|
||||||
@@ -218,34 +215,34 @@ function isToday(date: Date): boolean {
|
|||||||
return date.toDateString() === today.toDateString();
|
return date.toDateString() === today.toDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimedEvents(date: Date): CalendarOccurrence[] {
|
function getTimedEvents(date: Date): CalendarInstance[] {
|
||||||
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
|
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
function getEventColor(instance: CalendarInstance): string {
|
||||||
if (occurrence.color) return occurrence.color;
|
if (instance.color) return instance.color;
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
|
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
|
||||||
return calendar?.properties?.color || '#1976D2';
|
return calendar?.properties?.color || '#1976D2';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllDayEventStyle(segment: MultiDaySegment) {
|
function getAllDayEventStyle(segment: MultiDaySegment) {
|
||||||
return {
|
return {
|
||||||
backgroundColor: getEventColor(segment.occurrence),
|
backgroundColor: getEventColor(segment.instance),
|
||||||
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
||||||
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
||||||
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventStyle(occurrence: CalendarOccurrence) {
|
function getEventStyle(instance: CalendarInstance) {
|
||||||
const startTime = new Date(occurrence.startMs);
|
const startTime = new Date(instance.startMs);
|
||||||
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
||||||
const duration = occurrence.durationMs / (1000 * 60);
|
const duration = instance.durationMs / (1000 * 60);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: `${(start / 1440) * 100}%`,
|
top: `${(start / 1440) * 100}%`,
|
||||||
height: `${(duration / 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' });
|
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));
|
return formatTime(new Date(timestamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { parseCalendarDate } from '@/utils/date'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
event: any | null
|
event: any | null
|
||||||
@@ -11,19 +12,6 @@ const emit = defineEmits<{
|
|||||||
'click': [event: any]
|
'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 => {
|
const formatTime = (isoString: string | null | undefined): string => {
|
||||||
if (!isoString) return 'Not set'
|
if (!isoString) return 'Not set'
|
||||||
const date = new Date(isoString)
|
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(() => {
|
const popupStyle = computed(() => {
|
||||||
return {
|
return {
|
||||||
left: `${props.position.x}px`,
|
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" />
|
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
|
||||||
<div class="text-caption">
|
<div class="text-caption">
|
||||||
<div v-if="event.properties?.timeless">
|
<div v-if="event.properties?.timeless">
|
||||||
All Day - {{ new Date(event.properties.startsOn).toLocaleDateString() }}
|
All Day - {{ formatAllDayDate(event.properties.startsOn) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ formatTime(event.properties?.startsOn) }} - {{ formatTime(event.properties?.endsOn) }}
|
{{ formatTime(event.properties?.startsOn) }} - {{ formatTime(event.properties?.endsOn) }}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||||
import {
|
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
||||||
startOfDay,
|
import { shiftLocalMonths, startOfLocalDay } from '@/utils/date';
|
||||||
getMultiDaySegments,
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
type MultiDaySegment,
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
} 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 EVENT_HEIGHT = 22; // Height of each event row in pixels
|
||||||
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
|
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
|
||||||
@@ -21,10 +17,11 @@ interface WeekData {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
occurrenceIndex: CalendarOccurrenceIndex;
|
|
||||||
calendars: any[];
|
calendars: any[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const instancesStore = useChronoInstancesStore();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'event-click': [event: any];
|
'event-click': [event: any];
|
||||||
'date-click': [date: Date];
|
'date-click': [date: Date];
|
||||||
@@ -99,7 +96,7 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
const numWeeks = weeksNeeded.value;
|
const numWeeks = weeksNeeded.value;
|
||||||
|
|
||||||
for (let w = 0; w < numWeeks; w++) {
|
for (let w = 0; w < numWeeks; w++) {
|
||||||
const weekStart = startOfDay(new Date(current));
|
const weekStart = startOfLocalDay(new Date(current));
|
||||||
const weekEnd = new Date(weekStart);
|
const weekEnd = new Date(weekStart);
|
||||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||||
|
|
||||||
@@ -113,7 +110,7 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get multiday segments for this week
|
// Get multiday segments for this week
|
||||||
const occurrences = occurrencesForRange(props.occurrenceIndex, {
|
const instances = instancesStore.instancesForRange({
|
||||||
startMs: weekStart.getTime(),
|
startMs: weekStart.getTime(),
|
||||||
endMs: new Date(
|
endMs: new Date(
|
||||||
weekEnd.getFullYear(),
|
weekEnd.getFullYear(),
|
||||||
@@ -121,8 +118,8 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
weekEnd.getDate() + 1,
|
weekEnd.getDate() + 1,
|
||||||
).getTime(),
|
).getTime(),
|
||||||
});
|
});
|
||||||
const { segments, laneCount } = getMultiDaySegments(
|
const { segments, laneCount } = layoutMultiDayLanes(
|
||||||
occurrences,
|
instances,
|
||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
7,
|
7,
|
||||||
@@ -141,8 +138,8 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Single-day events for a specific date
|
// Single-day events for a specific date
|
||||||
function getSingleDayEvents(date: Date): CalendarOccurrence[] {
|
function getSingleDayEvents(date: Date): CalendarInstance[] {
|
||||||
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.multiDay);
|
return instancesStore.instancesForDay(date).filter(instance => !instance.multiDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
|
// 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();
|
return date.toDateString() === today.toDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
function getEventColor(instance: CalendarInstance): string {
|
||||||
if (occurrence.color) return occurrence.color;
|
if (instance.color) return instance.color;
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
|
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
|
||||||
return calendar?.properties?.color || '#1976D2';
|
return calendar?.properties?.color || '#1976D2';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -211,15 +208,15 @@ function getEventColor(occurrence: CalendarOccurrence): string {
|
|||||||
<!-- Single-day events -->
|
<!-- Single-day events -->
|
||||||
<div class="cell-events">
|
<div class="cell-events">
|
||||||
<div
|
<div
|
||||||
v-for="occurrence in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
v-for="instance in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
||||||
:key="occurrence.key"
|
:key="instance.key"
|
||||||
class="single-day-event"
|
class="single-day-event"
|
||||||
:style="{ backgroundColor: getEventColor(occurrence) }"
|
:style="{ backgroundColor: getEventColor(instance) }"
|
||||||
@click.stop="$emit('event-click', occurrence.entity)"
|
@click.stop="$emit('event-click', instance.entity)"
|
||||||
@mouseenter="$emit('event-hover', { event: $event, entity: occurrence.entity })"
|
@mouseenter="$emit('event-hover', { event: $event, entity: instance.entity })"
|
||||||
@mouseleave="$emit('event-hover-end')"
|
@mouseleave="$emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
{{ occurrence.label || 'Untitled' }}
|
{{ instance.label || 'Untitled' }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
||||||
@@ -236,24 +233,24 @@ function getEventColor(occurrence: CalendarOccurrence): string {
|
|||||||
<div class="multiday-overlay">
|
<div class="multiday-overlay">
|
||||||
<div
|
<div
|
||||||
v-for="segment in week.multiDaySegments"
|
v-for="segment in week.multiDaySegments"
|
||||||
:key="`${segment.occurrence.key}-${weekIndex}`"
|
:key="`${segment.instance.key}-${weekIndex}`"
|
||||||
class="multiday-event"
|
class="multiday-event"
|
||||||
:class="{
|
:class="{
|
||||||
'is-start': segment.isStart,
|
'is-start': segment.isStart,
|
||||||
'is-end': segment.isEnd,
|
'is-end': segment.isEnd,
|
||||||
}"
|
}"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: getEventColor(segment.occurrence),
|
backgroundColor: getEventColor(segment.instance),
|
||||||
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
||||||
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
||||||
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
|
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
|
||||||
}"
|
}"
|
||||||
@click.stop="$emit('event-click', segment.occurrence.entity)"
|
@click.stop="$emit('event-click', segment.instance.entity)"
|
||||||
@mouseenter="$emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
|
@mouseenter="$emit('event-hover', { event: $event, entity: segment.instance.entity })"
|
||||||
@mouseleave="$emit('event-hover-end')"
|
@mouseleave="$emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
<span v-if="segment.isStart" class="event-label">
|
<span v-if="segment.isStart" class="event-label">
|
||||||
{{ segment.occurrence.label || 'Untitled' }}
|
{{ segment.instance.label || 'Untitled' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { parseCalendarDate } from '@/utils/date'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'edit' | 'view'
|
mode: 'edit' | 'view'
|
||||||
@@ -42,6 +43,9 @@ const formatTime = (date: Date): string => {
|
|||||||
|
|
||||||
const formatDateTime = (isoString: string | null | undefined): string => {
|
const formatDateTime = (isoString: string | null | undefined): string => {
|
||||||
if (!isoString) return 'Not set'
|
if (!isoString) return 'Not set'
|
||||||
|
if (timelessValue.value) {
|
||||||
|
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
|
||||||
|
}
|
||||||
const date = new Date(isoString)
|
const date = new Date(isoString)
|
||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
@@ -53,6 +57,16 @@ const parseDateTime = (date: string, time: string): Date => {
|
|||||||
return new Date(`${date}T${time}`)
|
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 => {
|
const formatDisplayDate = (value: string): string => {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
|
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
@@ -101,10 +115,10 @@ const selectEndDate = (value: unknown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize date/time from props
|
// Initialize date/time from props
|
||||||
watch(() => props.startsOn, (newValue) => {
|
watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
const start = new Date(newValue)
|
const start = new Date(newValue)
|
||||||
startDate.value = formatDate(start)
|
startDate.value = inputDate(newValue, timeless === true)
|
||||||
startTime.value = formatTime(start)
|
startTime.value = formatTime(start)
|
||||||
} else {
|
} else {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -113,10 +127,10 @@ watch(() => props.startsOn, (newValue) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(() => props.endsOn, (newValue) => {
|
watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
const end = new Date(newValue)
|
const end = new Date(newValue)
|
||||||
endDate.value = formatDate(end)
|
endDate.value = inputDate(newValue, timeless === true)
|
||||||
endTime.value = formatTime(end)
|
endTime.value = formatTime(end)
|
||||||
} else {
|
} else {
|
||||||
const later = new Date(Date.now() + 60 * 60 * 1000)
|
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
|
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) return false
|
||||||
const start = parseDateTime(startDate.value, startTime.value)
|
const start = parseDateTime(startDate.value, startTime.value)
|
||||||
const end = parseDateTime(endDate.value, endTime.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)
|
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)
|
endDate.value = formatDate(correctedEnd)
|
||||||
endTime.value = formatTime(correctedEnd)
|
endTime.value = formatTime(correctedEnd)
|
||||||
return true
|
return true
|
||||||
@@ -140,8 +158,8 @@ const ensureValidRange = (): boolean => {
|
|||||||
|
|
||||||
watch([startDate, startTime, endDate, endTime, timelessValue], () => {
|
watch([startDate, startTime, endDate, endTime, timelessValue], () => {
|
||||||
if (props.mode !== 'edit' || ensureValidRange()) return
|
if (props.mode !== 'edit' || ensureValidRange()) return
|
||||||
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
|
emit('update:startsOn', serializeDateTime(startDate.value, startTime.value))
|
||||||
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
|
emit('update:endsOn', serializeDateTime(endDate.value, endTime.value))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useDisplay } from 'vuetify';
|
import { useDisplay } from 'vuetify';
|
||||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||||
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
|
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
|
||||||
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
|
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
|
||||||
import { useChronoUiStore } from '@/stores/chronoUiStore';
|
import { useChronoUiStore } from '@/stores/chronoUiStore';
|
||||||
@@ -28,6 +29,7 @@ const isChronoManagerAvailable = computed(() => {
|
|||||||
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const chronoInstancesStore = useChronoInstancesStore();
|
||||||
const chronoOperationsStore = useChronoOperationsStore();
|
const chronoOperationsStore = useChronoOperationsStore();
|
||||||
const chronoSettingsStore = useChronoSettingsStore();
|
const chronoSettingsStore = useChronoSettingsStore();
|
||||||
const chronoUiStore = useChronoUiStore();
|
const chronoUiStore = useChronoUiStore();
|
||||||
@@ -45,9 +47,10 @@ const {
|
|||||||
taskLists,
|
taskLists,
|
||||||
filteredTasks,
|
filteredTasks,
|
||||||
collections,
|
collections,
|
||||||
occurrenceIndex,
|
|
||||||
} = storeToRefs(chronoOperationsStore);
|
} = storeToRefs(chronoOperationsStore);
|
||||||
|
|
||||||
|
const { setVisibleRange } = chronoInstancesStore;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentDate,
|
currentDate,
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
@@ -91,7 +94,6 @@ const {
|
|||||||
} = chronoUiStore;
|
} = chronoUiStore;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setVisibleRange,
|
|
||||||
toggleCalendarVisibility,
|
toggleCalendarVisibility,
|
||||||
toggleTaskComplete,
|
toggleTaskComplete,
|
||||||
} = chronoOperationsStore;
|
} = chronoOperationsStore;
|
||||||
@@ -277,7 +279,6 @@ watch(() => route.fullPath, () => {
|
|||||||
v-if="!isTaskView"
|
v-if="!isTaskView"
|
||||||
:view="calendarView"
|
:view="calendarView"
|
||||||
:current-date="currentDate"
|
:current-date="currentDate"
|
||||||
:occurrence-index="occurrenceIndex"
|
|
||||||
:calendars="calendars"
|
:calendars="calendars"
|
||||||
:initial-days-span="daysViewSpan"
|
:initial-days-span="daysViewSpan"
|
||||||
:initial-agenda-view-span="agendaViewSpan"
|
:initial-agenda-view-span="agendaViewSpan"
|
||||||
|
|||||||
@@ -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 { defineStore } from 'pinia'
|
||||||
import { CollectionObject } from '@ChronoManager/models/collection'
|
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||||
import { EntityObject } from '@ChronoManager/models/entity'
|
import { EntityObject } from '@ChronoManager/models/entity'
|
||||||
@@ -9,8 +9,6 @@ import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
|||||||
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
||||||
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
||||||
import { useImportStore } from '@ChronoManager/stores/importStore'
|
import { useImportStore } from '@ChronoManager/stores/importStore'
|
||||||
import type { VisibleDateRange } from '@/types'
|
|
||||||
import { buildOccurrenceIndex, occurrencesForRange } from '@/utils/occurrenceIndex'
|
|
||||||
|
|
||||||
type ChronoEntityProperties = EventObject | TaskObject
|
type ChronoEntityProperties = EventObject | TaskObject
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
const importStore = useImportStore()
|
const importStore = useImportStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const visibleRange = shallowRef<VisibleDateRange | null>(null)
|
|
||||||
|
|
||||||
const collections = computed(() => collectionsStore.collections)
|
const collections = computed(() => collectionsStore.collections)
|
||||||
const entities = computed(() => entitiesStore.entities)
|
const entities = computed(() => entitiesStore.entities)
|
||||||
@@ -45,24 +42,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
|
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
|
||||||
})
|
})
|
||||||
const filteredTasks = computed(() => tasks.value)
|
const filteredTasks = computed(() => tasks.value)
|
||||||
const occurrenceIndex = computed(() => buildOccurrenceIndex(
|
|
||||||
filteredEvents.value,
|
|
||||||
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 {
|
function prepareEntityProperties(entity: EntityObject): ChronoEntityProperties {
|
||||||
const properties = entity.properties as ChronoEntityProperties
|
const properties = entity.properties as ChronoEntityProperties
|
||||||
@@ -175,7 +154,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
visibleRange,
|
|
||||||
collections,
|
collections,
|
||||||
entities,
|
entities,
|
||||||
calendars,
|
calendars,
|
||||||
@@ -184,9 +162,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
tasks,
|
tasks,
|
||||||
filteredEvents,
|
filteredEvents,
|
||||||
filteredTasks,
|
filteredTasks,
|
||||||
occurrenceIndex,
|
|
||||||
visibleOccurrences,
|
|
||||||
setVisibleRange,
|
|
||||||
saveEntity,
|
saveEntity,
|
||||||
deleteEntity,
|
deleteEntity,
|
||||||
toggleCalendarVisibility,
|
toggleCalendarVisibility,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||||
|
|
||||||
export interface CalendarOccurrence {
|
export interface CalendarInstance {
|
||||||
key: string
|
key: string
|
||||||
entity: EntityObject
|
entity: EntityObject
|
||||||
occurrenceId: string | null
|
instanceId: string | null
|
||||||
startMs: number
|
startMs: number
|
||||||
endMs: number
|
endMs: number
|
||||||
dayStartMs: number
|
dayStartMs: number
|
||||||
@@ -13,9 +13,3 @@ export interface CalendarOccurrence {
|
|||||||
label: string | null
|
label: string | null
|
||||||
color: 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'
|
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 {
|
export function startOfLocalDay(date: Date): Date {
|
||||||
const start = new Date(date)
|
const start = new Date(date)
|
||||||
start.setHours(0, 0, 0, 0)
|
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 { EntityObject } from '@ChronoManager/models/entity'
|
||||||
import type { EventObject } from '@ChronoManager/models/event'
|
import type { EventObject } from '@ChronoManager/models/event'
|
||||||
import type { EventMutationObject } from '@ChronoManager/models/event-mutation'
|
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 type { VisibleDateRange } from '../types'
|
||||||
import { shiftLocalDays, startOfLocalDay } from './dateRanges'
|
import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date'
|
||||||
|
|
||||||
const MINIMUM_DURATION_MS = 1
|
const MINIMUM_DURATION_MS = 1
|
||||||
|
|
||||||
@@ -13,6 +13,14 @@ function timestamp(value: string | null | undefined): number | null {
|
|||||||
return Number.isFinite(result) ? result : 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 {
|
function conclusionTimestamp(value: string | null | undefined): number | null {
|
||||||
if (!value) return 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)
|
return !range || (startMs < range.endMs && endMs > range.startMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOccurrence(
|
function createInstance(
|
||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
sourceStartMs: number,
|
sourceStartMs: number,
|
||||||
effectiveStartMs: number,
|
effectiveStartMs: number,
|
||||||
effectiveEndMs: number,
|
effectiveEndMs: number,
|
||||||
recurring: boolean,
|
recurring: boolean,
|
||||||
|
timeless: boolean,
|
||||||
mutation?: EventMutationObject,
|
mutation?: EventMutationObject,
|
||||||
): CalendarOccurrence {
|
): CalendarInstance {
|
||||||
const event = entity.properties as EventObject
|
const event = entity.properties as EventObject
|
||||||
const endMs = effectiveEndMs > effectiveStartMs
|
const endMs = effectiveEndMs > effectiveStartMs
|
||||||
? effectiveEndMs
|
? effectiveEndMs
|
||||||
: effectiveStartMs + MINIMUM_DURATION_MS
|
: effectiveStartMs + MINIMUM_DURATION_MS
|
||||||
const dayStartMs = startOfLocalDay(new Date(effectiveStartMs)).getTime()
|
const dayStartMs = startOfLocalDay(new Date(effectiveStartMs)).getTime()
|
||||||
const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).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 {
|
return {
|
||||||
key: `${String(entity.identifier)}:${occurrenceId ?? 'master'}`,
|
key: `${String(entity.identifier)}:${instanceId ?? 'master'}`,
|
||||||
entity,
|
entity,
|
||||||
occurrenceId,
|
instanceId,
|
||||||
startMs: effectiveStartMs,
|
startMs: effectiveStartMs,
|
||||||
endMs,
|
endMs,
|
||||||
dayStartMs,
|
dayStartMs,
|
||||||
durationMs: endMs - effectiveStartMs,
|
durationMs: endMs - effectiveStartMs,
|
||||||
timeless: (mutation?.timeless ?? event.timeless === true) || lastOccupiedDayMs !== dayStartMs,
|
timeless: timeless || lastOccupiedDayMs !== dayStartMs,
|
||||||
multiDay: lastOccupiedDayMs !== dayStartMs,
|
multiDay: lastOccupiedDayMs !== dayStartMs,
|
||||||
label: mutation?.label ?? event.label,
|
label: mutation?.label ?? event.label,
|
||||||
color: mutation?.color ?? event.color,
|
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>()
|
const mutations = new Map<number, EventMutationObject>()
|
||||||
|
|
||||||
for (const [key, mutation] of Object.entries(event.mutations ?? {})) {
|
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)
|
if (mutationId !== null) mutations.set(mutationId, mutation)
|
||||||
}
|
}
|
||||||
|
|
||||||
return mutations
|
return mutations
|
||||||
}
|
}
|
||||||
|
|
||||||
function materializeOccurrence(
|
function materializeInstance(
|
||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
sourceStartMs: number,
|
sourceStartMs: number,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
|
durationDays: number | null,
|
||||||
recurring: boolean,
|
recurring: boolean,
|
||||||
mutation: EventMutationObject | undefined,
|
mutation: EventMutationObject | undefined,
|
||||||
range?: VisibleDateRange,
|
range?: VisibleDateRange,
|
||||||
): CalendarOccurrence | null {
|
): CalendarInstance | null {
|
||||||
if (mutation?.mutationExclusion === true) return null
|
if (mutation?.mutationExclusion === true) return null
|
||||||
|
|
||||||
const effectiveStartMs = timestamp(mutation?.startsOn) ?? sourceStartMs
|
const event = entity.properties as EventObject
|
||||||
const effectiveEndMs = timestamp(mutation?.endsOn) ?? effectiveStartMs + durationMs
|
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
|
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 {
|
function localDayNumber(date: Date): number {
|
||||||
@@ -131,9 +155,10 @@ function expandDaily(
|
|||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
start: Date,
|
start: Date,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
|
durationDays: number | null,
|
||||||
range: VisibleDateRange,
|
range: VisibleDateRange,
|
||||||
mutations: Map<number, EventMutationObject>,
|
mutations: Map<number, EventMutationObject>,
|
||||||
): CalendarOccurrence[] {
|
): CalendarInstance[] {
|
||||||
const pattern = (entity.properties as EventObject).pattern!
|
const pattern = (entity.properties as EventObject).pattern!
|
||||||
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
||||||
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
||||||
@@ -147,7 +172,7 @@ function expandDaily(
|
|||||||
? dailyStartIndex(start, interval, durationMs, range)
|
? dailyStartIndex(start, interval, durationMs, range)
|
||||||
: 0
|
: 0
|
||||||
let occurrenceCount = weekdays ? 0 : step
|
let occurrenceCount = weekdays ? 0 : step
|
||||||
const occurrences: CalendarOccurrence[] = []
|
const instances: CalendarInstance[] = []
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const candidate = shiftLocalDays(start, step * interval)
|
const candidate = shiftLocalDays(start, step * interval)
|
||||||
@@ -159,30 +184,32 @@ function expandDaily(
|
|||||||
occurrenceCount += 1
|
occurrenceCount += 1
|
||||||
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break
|
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break
|
||||||
|
|
||||||
const occurrence = materializeOccurrence(
|
const instance = materializeInstance(
|
||||||
entity,
|
entity,
|
||||||
candidateMs,
|
candidateMs,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
durationDays,
|
||||||
true,
|
true,
|
||||||
mutations.get(candidateMs),
|
mutations.get(candidateMs),
|
||||||
range,
|
range,
|
||||||
)
|
)
|
||||||
if (occurrence) occurrences.push(occurrence)
|
if (instance) instances.push(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
step += 1
|
step += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return occurrences
|
return instances
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandWeekly(
|
function expandWeekly(
|
||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
start: Date,
|
start: Date,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
|
durationDays: number | null,
|
||||||
range: VisibleDateRange,
|
range: VisibleDateRange,
|
||||||
mutations: Map<number, EventMutationObject>,
|
mutations: Map<number, EventMutationObject>,
|
||||||
): CalendarOccurrence[] {
|
): CalendarInstance[] {
|
||||||
const pattern = (entity.properties as EventObject).pattern!
|
const pattern = (entity.properties as EventObject).pattern!
|
||||||
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
||||||
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
||||||
@@ -195,7 +222,7 @@ function expandWeekly(
|
|||||||
? weeklyStartIndex(weekStart, interval, durationMs, range)
|
? weeklyStartIndex(weekStart, interval, durationMs, range)
|
||||||
: 0
|
: 0
|
||||||
let occurrenceCount = maximumOccurrences === null ? week * weekdays.length : 0
|
let occurrenceCount = maximumOccurrences === null ? week * weekdays.length : 0
|
||||||
const occurrences: CalendarOccurrence[] = []
|
const instances: CalendarInstance[] = []
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7)
|
const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7)
|
||||||
@@ -205,79 +232,94 @@ function expandWeekly(
|
|||||||
const candidate = shiftLocalDays(intervalWeekStart, weekday - 1)
|
const candidate = shiftLocalDays(intervalWeekStart, weekday - 1)
|
||||||
const candidateMs = candidate.getTime()
|
const candidateMs = candidate.getTime()
|
||||||
if (candidateMs < start.getTime()) continue
|
if (candidateMs < start.getTime()) continue
|
||||||
if (candidateMs >= range.endMs) return occurrences
|
if (candidateMs >= range.endMs) return instances
|
||||||
if (concludesMs !== null && candidateMs > concludesMs) return occurrences
|
if (concludesMs !== null && candidateMs > concludesMs) return instances
|
||||||
|
|
||||||
occurrenceCount += 1
|
occurrenceCount += 1
|
||||||
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return occurrences
|
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return instances
|
||||||
|
|
||||||
const occurrence = materializeOccurrence(
|
const instance = materializeInstance(
|
||||||
entity,
|
entity,
|
||||||
candidateMs,
|
candidateMs,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
durationDays,
|
||||||
true,
|
true,
|
||||||
mutations.get(candidateMs),
|
mutations.get(candidateMs),
|
||||||
range,
|
range,
|
||||||
)
|
)
|
||||||
if (occurrence) occurrences.push(occurrence)
|
if (instance) instances.push(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
week += 1
|
week += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return occurrences
|
return instances
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendMovedMutations(
|
function appendMovedMutations(
|
||||||
occurrences: CalendarOccurrence[],
|
instances: CalendarInstance[],
|
||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
|
durationDays: number | null,
|
||||||
range: VisibleDateRange,
|
range: VisibleDateRange,
|
||||||
mutations: Map<number, EventMutationObject>,
|
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) {
|
for (const [sourceStartMs, mutation] of mutations) {
|
||||||
const occurrence = materializeOccurrence(
|
const instance = materializeInstance(
|
||||||
entity,
|
entity,
|
||||||
sourceStartMs,
|
sourceStartMs,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
durationDays,
|
||||||
true,
|
true,
|
||||||
mutation,
|
mutation,
|
||||||
range,
|
range,
|
||||||
)
|
)
|
||||||
if (occurrence && !present.has(occurrence.key)) {
|
if (instance && !present.has(instance.key)) {
|
||||||
occurrences.push(occurrence)
|
instances.push(instance)
|
||||||
present.add(occurrence.key)
|
present.add(instance.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expandEntityOccurrences(
|
export function expandEntityInstances(
|
||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
range?: VisibleDateRange,
|
range?: VisibleDateRange,
|
||||||
): CalendarOccurrence[] {
|
): CalendarInstance[] {
|
||||||
const event = entity.properties as EventObject
|
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 []
|
if (startMs === null) return []
|
||||||
|
|
||||||
const parsedEndMs = timestamp(event.endsOn)
|
const parsedEndMs = eventTimestamp(event.endsOn, timeless)
|
||||||
const durationMs = parsedEndMs !== null && parsedEndMs > startMs
|
const durationMs = parsedEndMs !== null && parsedEndMs > startMs
|
||||||
? parsedEndMs - startMs
|
? parsedEndMs - startMs
|
||||||
: MINIMUM_DURATION_MS
|
: 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
|
const pattern = event.pattern
|
||||||
|
|
||||||
if (!pattern || !range || (pattern.precision !== 'daily' && pattern.precision !== 'weekly')) {
|
if (!pattern || !range || (pattern.precision !== 'daily' && pattern.precision !== 'weekly')) {
|
||||||
const occurrence = materializeOccurrence(entity, startMs, durationMs, false, undefined, range)
|
const instance = materializeInstance(
|
||||||
return occurrence ? [occurrence] : []
|
entity,
|
||||||
|
startMs,
|
||||||
|
durationMs,
|
||||||
|
durationDays,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
range,
|
||||||
|
)
|
||||||
|
return instance ? [instance] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutations = mutationMap(event)
|
const mutations = mutationMap(event, timeless)
|
||||||
const occurrences = pattern.precision === 'daily'
|
const instances = pattern.precision === 'daily'
|
||||||
? expandDaily(entity, new Date(startMs), durationMs, range, mutations)
|
? expandDaily(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
||||||
: expandWeekly(entity, new Date(startMs), durationMs, range, mutations)
|
: expandWeekly(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
||||||
|
|
||||||
appendMovedMutations(occurrences, entity, durationMs, range, mutations)
|
appendMovedMutations(instances, entity, durationMs, durationDays, range, mutations)
|
||||||
occurrences.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
|
instances.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
|
||||||
return occurrences
|
return instances
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ describe('occurrence index', () => {
|
|||||||
expect(occurrencesForDay(index, new Date(2026, 5, 21))).toHaveLength(0)
|
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', () => {
|
it('retrieves overlapping occurrences without duplicates', () => {
|
||||||
const spanning = eventEntity(
|
const spanning = eventEntity(
|
||||||
'spanning',
|
'spanning',
|
||||||
|
|||||||
@@ -87,6 +87,32 @@ describe('daily recurrence', () => {
|
|||||||
|
|
||||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([9, 9, 9])
|
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', () => {
|
describe('weekly recurrence', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user