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>
</template>
<template v-for="(dayOccurrences, date) in groupedEvents" :key="date">
<template v-for="(dayInstances, date) in groupedEvents" :key="date">
<v-list-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
<v-list-item
v-for="occurrence in dayOccurrences"
:key="occurrence.key"
@click="$emit('event-click', occurrence.entity)"
v-for="instance in dayInstances"
:key="instance.key"
@click="$emit('event-click', instance.entity)"
>
<template #prepend>
<v-icon :color="getEventColor(occurrence)">mdi-circle</v-icon>
<v-icon :color="getEventColor(instance)">mdi-circle</v-icon>
</template>
<v-list-item-title>{{ occurrence.label || 'Untitled' }}</v-list-item-title>
<v-list-item-title>{{ instance.label || 'Untitled' }}</v-list-item-title>
<v-list-item-subtitle>
{{ occurrence.timeless ? 'All day' : `${formatOccurrenceTime(occurrence.startMs)} - ${formatOccurrenceTime(occurrence.endMs)}` }}
{{ instance.timeless ? 'All day' : `${formatInstanceTime(instance.startMs)} - ${formatInstanceTime(instance.endMs)}` }}
</v-list-item-subtitle>
</v-list-item>
</template>
@@ -52,20 +52,21 @@ import { ref, computed, watch } from 'vue';
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
import { occurrencesForRange } from '@/utils/occurrenceIndex';
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
import { daysVisibleRange, shiftLocalDays } from '@/utils/date';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance';
type CalendarEntity = EntityObject;
type CalendarCollection = CollectionObject;
const props = defineProps<{
occurrenceIndex: CalendarOccurrenceIndex;
calendars: CalendarCollection[];
currentDate: Date;
initialSpan?: AgendaViewSpan;
}>();
const instancesStore = useChronoInstancesStore();
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
const emit = defineEmits<{
@@ -127,28 +128,28 @@ const dateRangeLabel = computed(() => {
});
const groupedEvents = computed(() => {
const grouped: Record<string, CalendarOccurrence[]> = {};
const grouped: Record<string, CalendarInstance[]> = {};
const { start, end } = dateRange.value;
const occurrences = occurrencesForRange(props.occurrenceIndex, {
const instances = instancesStore.instancesForRange({
startMs: start.getTime(),
endMs: end.getTime(),
});
occurrences.forEach(occurrence => {
const dateKey = new Date(occurrence.startMs).toDateString();
instances.forEach(instance => {
const dateKey = new Date(instance.startMs).toDateString();
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
grouped[dateKey].push(occurrence);
grouped[dateKey].push(instance);
});
return grouped;
});
function getEventColor(occurrence: CalendarOccurrence): string {
const entity = occurrence.entity;
if (occurrence.color) return occurrence.color;
function getEventColor(instance: CalendarInstance): string {
const entity = instance.entity;
if (instance.color) return instance.color;
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
return calendar?.properties?.color || '#1976D2';
}
@@ -157,7 +158,7 @@ function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
function formatOccurrenceTime(timestamp: number): string {
function formatInstanceTime(timestamp: number): string {
return formatTime(new Date(timestamp));
}
+1 -6
View File
@@ -9,7 +9,6 @@
<MonthView
v-if="view === 'month'"
:current-date="currentDate"
:occurrence-index="occurrenceIndex"
:calendars="calendars"
@event-click="$emit('event-click', $event)"
@date-click="$emit('date-click', $event)"
@@ -20,7 +19,6 @@
<DaysView
v-else-if="view === 'days'"
:current-date="currentDate"
:occurrence-index="occurrenceIndex"
:calendars="calendars"
:initial-span="initialDaysSpan"
@event-click="$emit('event-click', $event)"
@@ -32,7 +30,6 @@
/>
<AgendaView
v-else-if="view === 'agenda'"
:occurrence-index="occurrenceIndex"
:calendars="calendars"
:current-date="currentDate"
:initial-span="initialAgendaViewSpan"
@@ -53,13 +50,11 @@ import { spanToDays, type AgendaViewSpan, type DaysViewSpan } from '@/types/span
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import type { VisibleDateRange } from '@/types';
import type { CalendarOccurrenceIndex } from '@/types/occurrence';
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/dateRanges';
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/date.ts';
const props = defineProps<{
view: 'days' | 'month' | 'agenda';
currentDate: Date;
occurrenceIndex: CalendarOccurrenceIndex;
calendars: CollectionObject[];
initialDaysSpan?: DaysViewSpan;
initialAgendaViewSpan?: AgendaViewSpan;
+35 -38
View File
@@ -45,18 +45,18 @@
<div class="all-day-events-overlay">
<div
v-for="segment in allDaySegments.segments"
:key="segment.occurrence.key"
:key="segment.instance.key"
class="all-day-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="getAllDayEventStyle(segment)"
@click="emit('event-click', segment.occurrence.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
@click="emit('event-click', segment.instance.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: segment.instance.entity })"
@mouseleave="emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">{{ segment.occurrence.label || 'Untitled' }}</span>
<span v-if="segment.isStart" class="event-label">{{ segment.instance.label || 'Untitled' }}</span>
</div>
</div>
</div>
@@ -73,22 +73,22 @@
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
<div class="day-events" @click="handleDayClick($event, day)">
<div
v-for="occurrence in getTimedEvents(day)"
:key="occurrence.key"
v-for="instance in getTimedEvents(day)"
:key="instance.key"
class="day-event"
:style="getEventStyle(occurrence)"
@click.stop="emit('event-click', occurrence.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: occurrence.entity })"
:style="getEventStyle(instance)"
@click.stop="emit('event-click', instance.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: instance.entity })"
@mouseleave="emit('event-hover-end')"
>
<template v-if="daysCount <= 3">
<div class="event-time">
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
{{ formatInstanceTime(instance.startMs) }} - {{ formatInstanceTime(instance.endMs) }}
</div>
<div class="event-title">{{ occurrence.label || 'Untitled' }}</div>
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
</template>
<template v-else>
<div class="event-title-compact">{{ occurrence.label || 'Untitled' }}</div>
<div class="event-title-compact">{{ instance.label || 'Untitled' }}</div>
</template>
</div>
</div>
@@ -101,17 +101,13 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
startOfDay,
getMultiDaySegments,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
import { shiftLocalDays } from '@/utils/dateRanges';
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
import { shiftLocalDays, startOfLocalDay } from '@/utils/date';
import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance';
const ALL_DAY_EVENT_HEIGHT = 24;
@@ -120,11 +116,12 @@ type CalendarCollection = CollectionObject;
const props = defineProps<{
currentDate: Date;
occurrenceIndex: CalendarOccurrenceIndex;
calendars: CalendarCollection[];
initialSpan?: DaysViewSpan;
}>();
const instancesStore = useChronoInstancesStore();
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
const daysCount = computed(() => spanToDays(selectedSpan.value));
@@ -197,20 +194,20 @@ const allDaySegments = computed(() => {
return { segments: [], laneCount: 0 };
}
const rangeStart = startOfDay(visibleDates.value[0]);
const rangeEnd = startOfDay(visibleDates.value[visibleDates.value.length - 1]);
const rangeStart = startOfLocalDay(visibleDates.value[0]);
const rangeEnd = startOfLocalDay(visibleDates.value[visibleDates.value.length - 1]);
const getColumnIndex = (date: Date): number => {
const targetDay = startOfDay(date);
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
const targetDay = startOfLocalDay(date);
return visibleDates.value.findIndex(d => startOfLocalDay(d).getTime() === targetDay.getTime());
};
const occurrences = occurrencesForRange(props.occurrenceIndex, {
const instances = instancesStore.instancesForRange({
startMs: rangeStart.getTime(),
endMs: shiftLocalDays(rangeEnd, 1).getTime(),
});
return getMultiDaySegments(occurrences, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
return layoutMultiDayLanes(instances, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
});
function isToday(date: Date): boolean {
@@ -218,34 +215,34 @@ function isToday(date: Date): boolean {
return date.toDateString() === today.toDateString();
}
function getTimedEvents(date: Date): CalendarOccurrence[] {
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
function getTimedEvents(date: Date): CalendarInstance[] {
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
}
function getEventColor(occurrence: CalendarOccurrence): string {
if (occurrence.color) return occurrence.color;
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
function getEventColor(instance: CalendarInstance): string {
if (instance.color) return instance.color;
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
return calendar?.properties?.color || '#1976D2';
}
function getAllDayEventStyle(segment: MultiDaySegment) {
return {
backgroundColor: getEventColor(segment.occurrence),
backgroundColor: getEventColor(segment.instance),
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
};
}
function getEventStyle(occurrence: CalendarOccurrence) {
const startTime = new Date(occurrence.startMs);
function getEventStyle(instance: CalendarInstance) {
const startTime = new Date(instance.startMs);
const start = startTime.getHours() * 60 + startTime.getMinutes();
const duration = occurrence.durationMs / (1000 * 60);
const duration = instance.durationMs / (1000 * 60);
return {
top: `${(start / 1440) * 100}%`,
height: `${(duration / 1440) * 100}%`,
backgroundColor: getEventColor(occurrence),
backgroundColor: getEventColor(instance),
};
}
@@ -259,7 +256,7 @@ function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
function formatOccurrenceTime(timestamp: number): string {
function formatInstanceTime(timestamp: number): string {
return formatTime(new Date(timestamp));
}
+6 -14
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { parseCalendarDate } from '@/utils/date'
const props = defineProps<{
event: any | null
@@ -11,19 +12,6 @@ const emit = defineEmits<{
'click': [event: any]
}>()
const formatDateTime = (isoString: string | null | undefined): string => {
if (!isoString) return 'Not set'
const date = new Date(isoString)
return date.toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
const formatTime = (isoString: string | null | undefined): string => {
if (!isoString) return 'Not set'
const date = new Date(isoString)
@@ -33,6 +21,10 @@ const formatTime = (isoString: string | null | undefined): string => {
})
}
const formatAllDayDate = (isoString: string | null | undefined): string => {
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
}
const popupStyle = computed(() => {
return {
left: `${props.position.x}px`,
@@ -83,7 +75,7 @@ const participantCount = computed(() => {
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
<div class="text-caption">
<div v-if="event.properties?.timeless">
All Day - {{ new Date(event.properties.startsOn).toLocaleDateString() }}
All Day - {{ formatAllDayDate(event.properties.startsOn) }}
</div>
<div v-else>
{{ formatTime(event.properties?.startsOn) }} - {{ formatTime(event.properties?.endsOn) }}
+26 -29
View File
@@ -1,13 +1,9 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import {
startOfDay,
getMultiDaySegments,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
import { shiftLocalMonths } from '@/utils/dateRanges';
import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
import { shiftLocalMonths, startOfLocalDay } from '@/utils/date';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance';
const EVENT_HEIGHT = 22; // Height of each event row in pixels
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
@@ -21,10 +17,11 @@ interface WeekData {
const props = defineProps<{
currentDate: Date;
occurrenceIndex: CalendarOccurrenceIndex;
calendars: any[];
}>();
const instancesStore = useChronoInstancesStore();
const emit = defineEmits<{
'event-click': [event: any];
'date-click': [date: Date];
@@ -99,7 +96,7 @@ const weeks = computed<WeekData[]>(() => {
const numWeeks = weeksNeeded.value;
for (let w = 0; w < numWeeks; w++) {
const weekStart = startOfDay(new Date(current));
const weekStart = startOfLocalDay(new Date(current));
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
@@ -113,7 +110,7 @@ const weeks = computed<WeekData[]>(() => {
}
// Get multiday segments for this week
const occurrences = occurrencesForRange(props.occurrenceIndex, {
const instances = instancesStore.instancesForRange({
startMs: weekStart.getTime(),
endMs: new Date(
weekEnd.getFullYear(),
@@ -121,8 +118,8 @@ const weeks = computed<WeekData[]>(() => {
weekEnd.getDate() + 1,
).getTime(),
});
const { segments, laneCount } = getMultiDaySegments(
occurrences,
const { segments, laneCount } = layoutMultiDayLanes(
instances,
weekStart,
weekEnd,
7,
@@ -141,8 +138,8 @@ const weeks = computed<WeekData[]>(() => {
});
// Single-day events for a specific date
function getSingleDayEvents(date: Date): CalendarOccurrence[] {
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.multiDay);
function getSingleDayEvents(date: Date): CalendarInstance[] {
return instancesStore.instancesForDay(date).filter(instance => !instance.multiDay);
}
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
@@ -161,9 +158,9 @@ function isToday(date: Date): boolean {
return date.toDateString() === today.toDateString();
}
function getEventColor(occurrence: CalendarOccurrence): string {
if (occurrence.color) return occurrence.color;
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
function getEventColor(instance: CalendarInstance): string {
if (instance.color) return instance.color;
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
return calendar?.properties?.color || '#1976D2';
}
</script>
@@ -211,15 +208,15 @@ function getEventColor(occurrence: CalendarOccurrence): string {
<!-- Single-day events -->
<div class="cell-events">
<div
v-for="occurrence in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
:key="occurrence.key"
v-for="instance in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
:key="instance.key"
class="single-day-event"
:style="{ backgroundColor: getEventColor(occurrence) }"
@click.stop="$emit('event-click', occurrence.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: occurrence.entity })"
:style="{ backgroundColor: getEventColor(instance) }"
@click.stop="$emit('event-click', instance.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: instance.entity })"
@mouseleave="$emit('event-hover-end')"
>
{{ occurrence.label || 'Untitled' }}
{{ instance.label || 'Untitled' }}
</div>
<div
v-if="getHiddenCount(cell, week.laneCount) > 0"
@@ -236,24 +233,24 @@ function getEventColor(occurrence: CalendarOccurrence): string {
<div class="multiday-overlay">
<div
v-for="segment in week.multiDaySegments"
:key="`${segment.occurrence.key}-${weekIndex}`"
:key="`${segment.instance.key}-${weekIndex}`"
class="multiday-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="{
backgroundColor: getEventColor(segment.occurrence),
backgroundColor: getEventColor(segment.instance),
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
width: `calc(${segment.span} / 7 * 100% - 8px)`,
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
}"
@click.stop="$emit('event-click', segment.occurrence.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
@click.stop="$emit('event-click', segment.instance.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: segment.instance.entity })"
@mouseleave="$emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">
{{ segment.occurrence.label || 'Untitled' }}
{{ segment.instance.label || 'Untitled' }}
</span>
</div>
</div>
+26 -8
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { parseCalendarDate } from '@/utils/date'
interface Props {
mode: 'edit' | 'view'
@@ -42,6 +43,9 @@ const formatTime = (date: Date): string => {
const formatDateTime = (isoString: string | null | undefined): string => {
if (!isoString) return 'Not set'
if (timelessValue.value) {
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
}
const date = new Date(isoString)
return date.toLocaleString()
}
@@ -53,6 +57,16 @@ const parseDateTime = (date: string, time: string): Date => {
return new Date(`${date}T${time}`)
}
const serializeDateTime = (date: string, time: string): string => {
return timelessValue.value
? `${date}T00:00:00.000Z`
: new Date(`${date}T${time}`).toISOString()
}
const inputDate = (value: string, timeless: boolean): string => {
return timeless ? value.slice(0, 10) : formatDate(new Date(value))
}
const formatDisplayDate = (value: string): string => {
if (!value) return ''
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
@@ -101,10 +115,10 @@ const selectEndDate = (value: unknown) => {
}
// Initialize date/time from props
watch(() => props.startsOn, (newValue) => {
watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
if (newValue) {
const start = new Date(newValue)
startDate.value = formatDate(start)
startDate.value = inputDate(newValue, timeless === true)
startTime.value = formatTime(start)
} else {
const now = new Date()
@@ -113,10 +127,10 @@ watch(() => props.startsOn, (newValue) => {
}
}, { immediate: true })
watch(() => props.endsOn, (newValue) => {
watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
if (newValue) {
const end = new Date(newValue)
endDate.value = formatDate(end)
endDate.value = inputDate(newValue, timeless === true)
endTime.value = formatTime(end)
} else {
const later = new Date(Date.now() + 60 * 60 * 1000)
@@ -129,10 +143,14 @@ const ensureValidRange = (): boolean => {
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) return false
const start = parseDateTime(startDate.value, startTime.value)
const end = parseDateTime(endDate.value, endTime.value)
if (end.getTime() >= start.getTime()) return false
const valid = timelessValue.value
? end.getTime() > start.getTime()
: end.getTime() >= start.getTime()
if (valid) return false
const correctedEnd = new Date(start)
if (!timelessValue.value) correctedEnd.setHours(correctedEnd.getHours() + 1)
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
else correctedEnd.setHours(correctedEnd.getHours() + 1)
endDate.value = formatDate(correctedEnd)
endTime.value = formatTime(correctedEnd)
return true
@@ -140,8 +158,8 @@ const ensureValidRange = (): boolean => {
watch([startDate, startTime, endDate, endTime, timelessValue], () => {
if (props.mode !== 'edit' || ensureValidRange()) return
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
emit('update:startsOn', serializeDateTime(startDate.value, startTime.value))
emit('update:endsOn', serializeDateTime(endDate.value, endTime.value))
})
</script>