refactor: event instances

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-03 19:10:00 -04:00
parent acbe0ad5ca
commit 37fa7b911e
17 changed files with 498 additions and 479 deletions
+21 -20
View File
@@ -27,19 +27,19 @@
<v-list-item-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title> <v-list-item-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));
} }
+1 -6
View File
@@ -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
View File
@@ -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));
} }
+6 -14
View File
@@ -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) }}
+26 -29
View File
@@ -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>
+26 -8
View File
@@ -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>
+4 -3
View File
@@ -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"
+126
View File
@@ -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 -26
View File
@@ -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[]
}
-193
View File
@@ -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)
+104
View File
@@ -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 };
}
-87
View File
@@ -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
View File
@@ -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
} }
+15
View File
@@ -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',
+26
View File
@@ -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', () => {