- {{ formatEventDateTime(getEventProperties(entity).startsOn) }} - {{ formatEventDateTime(getEventProperties(entity).endsOn) }}
+ {{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
- {{ getEventProperties(entity).label || 'Untitled' }}
+ {{ getEventProperties(occurrence.entity).label || 'Untitled' }}
- {{ getEventProperties(entity).label || 'Untitled' }}
+ {{ getEventProperties(occurrence.entity).label || 'Untitled' }}
@@ -104,7 +104,6 @@ import { ref, computed, watch } from 'vue';
import {
startOfDay,
getMultiDaySegments,
- getTimedEventsForDate,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
import { shiftLocalDays } from '@/utils/dateRanges';
@@ -112,6 +111,8 @@ import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { EventObject } from '@ChronoManager/models/event';
+import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
+import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
const ALL_DAY_EVENT_HEIGHT = 24;
@@ -120,7 +121,7 @@ type CalendarCollection = CollectionObject;
const props = defineProps<{
currentDate: Date;
- events: CalendarEntity[];
+ occurrenceIndex: CalendarOccurrenceIndex;
calendars: CalendarCollection[];
initialSpan?: DaysViewSpan;
}>();
@@ -205,7 +206,12 @@ const allDaySegments = computed(() => {
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
};
- return getMultiDaySegments(props.events, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
+ const occurrences = occurrencesForRange(props.occurrenceIndex, {
+ startMs: rangeStart.getTime(),
+ endMs: shiftLocalDays(rangeEnd, 1).getTime(),
+ });
+
+ return getMultiDaySegments(occurrences, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
});
function isToday(date: Date): boolean {
@@ -213,8 +219,8 @@ function isToday(date: Date): boolean {
return date.toDateString() === today.toDateString();
}
-function getTimedEvents(date: Date): CalendarEntity[] {
- return getTimedEventsForDate(props.events, date) as CalendarEntity[];
+function getTimedEvents(date: Date): CalendarOccurrence[] {
+ return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
}
function getEventProperties(entity: CalendarEntity): EventObject {
@@ -230,28 +236,22 @@ function getEventColor(entity: CalendarEntity): string {
function getAllDayEventStyle(segment: MultiDaySegment) {
return {
- backgroundColor: getEventColor(segment.entity),
+ backgroundColor: getEventColor(segment.occurrence.entity),
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
};
}
-function getEventStyle(entity: CalendarEntity) {
- const event = getEventProperties(entity);
-
- if (!event.startsOn || !event.endsOn) {
- return { display: 'none' };
- }
- const startTime = new Date(event.startsOn);
- const endTime = new Date(event.endsOn);
+function getEventStyle(occurrence: CalendarOccurrence) {
+ const startTime = new Date(occurrence.startMs);
const start = startTime.getHours() * 60 + startTime.getMinutes();
- const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
+ const duration = occurrence.durationMs / (1000 * 60);
return {
top: `${(start / 1440) * 100}%`,
height: `${(duration / 1440) * 100}%`,
- backgroundColor: getEventColor(entity),
+ backgroundColor: getEventColor(occurrence.entity),
};
}
@@ -265,8 +265,8 @@ function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
-function formatEventDateTime(value: string | null): string {
- return value ? formatTime(new Date(value)) : '';
+function formatOccurrenceTime(timestamp: number): string {
+ return formatTime(new Date(timestamp));
}
function formatWeekDay(date: Date): string {
diff --git a/src/components/MonthView.vue b/src/components/MonthView.vue
index 4954604..cf71607 100644
--- a/src/components/MonthView.vue
+++ b/src/components/MonthView.vue
@@ -2,12 +2,12 @@
import { computed, ref, onMounted, onUnmounted } from 'vue';
import {
startOfDay,
- daysDiff,
- isMultiDay,
- getSingleDayEventsForDate,
+ getMultiDaySegments,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
import { shiftLocalMonths } from '@/utils/dateRanges';
+import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
+import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
const EVENT_HEIGHT = 22; // Height of each event row in pixels
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
@@ -21,7 +21,7 @@ interface WeekData {
const props = defineProps<{
currentDate: Date;
- events: any[];
+ occurrenceIndex: CalendarOccurrenceIndex;
calendars: any[];
}>();
@@ -113,7 +113,21 @@ const weeks = computed
(() => {
}
// Get multiday segments for this week
- const { segments, laneCount } = getMultiDaySegments(weekStart, weekEnd);
+ const occurrences = occurrencesForRange(props.occurrenceIndex, {
+ startMs: weekStart.getTime(),
+ endMs: new Date(
+ weekEnd.getFullYear(),
+ weekEnd.getMonth(),
+ weekEnd.getDate() + 1,
+ ).getTime(),
+ });
+ const { segments, laneCount } = getMultiDaySegments(
+ occurrences,
+ weekStart,
+ weekEnd,
+ 7,
+ date => date.getDay(),
+ );
weeksData.push({
startDate: weekStart,
@@ -126,77 +140,9 @@ const weeks = computed(() => {
return weeksData;
});
-function getMultiDaySegments(weekStart: Date, weekEnd: Date): { segments: MultiDaySegment[]; laneCount: number } {
- const segments: MultiDaySegment[] = [];
-
- // Filter multiday events that overlap this week
- const multiDayEvents = props.events.filter(entity => {
- if (!entity.properties?.startsOn || !isMultiDay(entity)) return false;
- const eventStart = startOfDay(new Date(entity.properties.startsOn));
- const eventEnd = startOfDay(new Date(entity.properties.endsOn));
- return eventStart <= weekEnd && eventEnd >= weekStart;
- });
-
- // Sort: longer events first, then by start date
- multiDayEvents.sort((a, b) => {
- const aStart = new Date(a.properties.startsOn);
- const aEnd = new Date(a.properties.endsOn);
- const bStart = new Date(b.properties.startsOn);
- const bEnd = new Date(b.properties.endsOn);
- const aDuration = daysDiff(aStart, aEnd);
- const bDuration = daysDiff(bStart, bEnd);
- if (bDuration !== aDuration) return bDuration - aDuration;
- return aStart.getTime() - bStart.getTime();
- });
-
- // Lane assignment
- const lanes: boolean[][] = []; // lanes[lane][dayOfWeek] = occupied
-
- for (const entity of multiDayEvents) {
- const eventStart = startOfDay(new Date(entity.properties.startsOn));
- const eventEnd = startOfDay(new Date(entity.properties.endsOn));
-
- // Clamp to this week
- const segStart = eventStart < weekStart ? weekStart : eventStart;
- const segEnd = eventEnd > weekEnd ? weekEnd : eventEnd;
-
- const startCol = segStart.getDay();
- const endCol = segEnd.getDay();
- const span = endCol - startCol + 1;
-
- // Find available lane
- let lane = 0;
- while (true) {
- if (!lanes[lane]) lanes[lane] = [false, false, false, false, false, false, false];
-
- let isFree = true;
- for (let d = startCol; d <= endCol; d++) {
- if (lanes[lane][d]) { isFree = false; break; }
- }
-
- if (isFree) {
- for (let d = startCol; d <= endCol; d++) lanes[lane][d] = true;
- break;
- }
- lane++;
- }
-
- segments.push({
- entity,
- startCol,
- span,
- lane,
- isStart: eventStart >= weekStart,
- isEnd: eventEnd <= weekEnd,
- });
- }
-
- return { segments, laneCount: lanes.length };
-}
-
// Single-day events for a specific date
-function getSingleDayEvents(date: Date): any[] {
- return getSingleDayEventsForDate(props.events, date);
+function getSingleDayEvents(date: Date): CalendarOccurrence[] {
+ return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.multiDay);
}
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
@@ -215,7 +161,7 @@ function isToday(date: Date): boolean {
return date.toDateString() === today.toDateString();
}
-function getEventColor(entity: any): string {
+function getEventColor(entity: CalendarOccurrence['entity']): string {
if (entity.properties?.color) return entity.properties.color;
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
return calendar?.properties?.color || '#1976D2';
@@ -265,15 +211,15 @@ function getEventColor(entity: any): string {
- {{ entity.properties?.label || 'Untitled' }}
+ {{ occurrence.entity.properties?.label || 'Untitled' }}
- {{ segment.entity.properties?.label || 'Untitled' }}
+ {{ segment.occurrence.entity.properties?.label || 'Untitled' }}
diff --git a/src/pages/ChronoPage.vue b/src/pages/ChronoPage.vue
index 61b3f3b..c5eb8e6 100644
--- a/src/pages/ChronoPage.vue
+++ b/src/pages/ChronoPage.vue
@@ -43,9 +43,9 @@ const {
loading,
calendars,
taskLists,
- filteredEvents,
filteredTasks,
collections,
+ occurrenceIndex,
} = storeToRefs(chronoOperationsStore);
const {
@@ -277,7 +277,7 @@ watch(() => route.fullPath, () => {
v-if="!isTaskView"
:view="calendarView"
:current-date="currentDate"
- :events="filteredEvents"
+ :occurrence-index="occurrenceIndex"
:calendars="calendars"
:initial-days-span="daysViewSpan"
:initial-agenda-view-span="agendaViewSpan"
diff --git a/src/stores/chronoOperationsStore.ts b/src/stores/chronoOperationsStore.ts
index 5eaaa03..0b04980 100644
--- a/src/stores/chronoOperationsStore.ts
+++ b/src/stores/chronoOperationsStore.ts
@@ -10,6 +10,7 @@ import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
import { useImportStore } from '@ChronoManager/stores/importStore'
import type { VisibleDateRange } from '@/types'
+import { buildOccurrenceIndex, occurrencesForRange } from '@/utils/occurrenceIndex'
type ChronoEntityProperties = EventObject | TaskObject
@@ -44,6 +45,10 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
})
const filteredTasks = computed(() => tasks.value)
+ const occurrenceIndex = computed(() => buildOccurrenceIndex(filteredEvents.value))
+ const visibleOccurrences = computed(() => visibleRange.value
+ ? occurrencesForRange(occurrenceIndex.value, visibleRange.value)
+ : [])
function setVisibleRange(range: VisibleDateRange) {
if (
@@ -176,6 +181,8 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
tasks,
filteredEvents,
filteredTasks,
+ occurrenceIndex,
+ visibleOccurrences,
setVisibleRange,
saveEntity,
deleteEntity,
diff --git a/src/types/occurrence.ts b/src/types/occurrence.ts
new file mode 100644
index 0000000..8b20986
--- /dev/null
+++ b/src/types/occurrence.ts
@@ -0,0 +1,19 @@
+import type { EntityObject } from '@ChronoManager/models/entity'
+
+export interface CalendarOccurrence {
+ key: string
+ entity: EntityObject
+ occurrenceId: string | null
+ startMs: number
+ endMs: number
+ dayStartMs: number
+ durationMs: number
+ timeless: boolean
+ multiDay: boolean
+}
+
+export interface CalendarOccurrenceIndex {
+ byDay: Map
+ byEntity: Map
+ sorted: CalendarOccurrence[]
+}
diff --git a/src/utils/calendarHelpers.ts b/src/utils/calendarHelpers.ts
index db5b8b3..49110ae 100644
--- a/src/utils/calendarHelpers.ts
+++ b/src/utils/calendarHelpers.ts
@@ -3,8 +3,10 @@
* Shared logic for multi-day event handling across calendar views
*/
+import type { CalendarOccurrence } from '@/types/occurrence'
+
export interface MultiDaySegment {
- entity: any;
+ occurrence: CalendarOccurrence;
startCol: number;
span: number;
lane: number;
@@ -85,7 +87,7 @@ export function eventOnDate(entity: any, date: Date): boolean {
* Used for rendering spanning events across columns
*/
export function getMultiDaySegments(
- events: any[],
+ occurrences: CalendarOccurrence[],
rangeStart: Date,
rangeEnd: Date,
columnCount: number,
@@ -94,30 +96,28 @@ export function getMultiDaySegments(
const segments: MultiDaySegment[] = [];
// Filter multi-day/all-day events that overlap this range
- const multiDayEvents = events.filter(entity => {
- if (!entity.properties?.startsOn) return false;
- if (!isAllDay(entity)) return false;
- return eventOverlapsRange(entity, rangeStart, rangeEnd);
+ const rangeStartMs = startOfDay(rangeStart).getTime();
+ const rangeEndExclusive = startOfDay(new Date(rangeEnd));
+ rangeEndExclusive.setDate(rangeEndExclusive.getDate() + 1);
+
+ const multiDayEvents = occurrences.filter(occurrence => {
+ return occurrence.timeless
+ && occurrence.startMs < rangeEndExclusive.getTime()
+ && occurrence.endMs > rangeStartMs;
});
// Sort: longer events first, then by start date
multiDayEvents.sort((a, b) => {
- const aStart = new Date(a.properties.startsOn);
- const aEnd = new Date(a.properties.endsOn || a.properties.startsOn);
- const bStart = new Date(b.properties.startsOn);
- const bEnd = new Date(b.properties.endsOn || b.properties.startsOn);
- const aDuration = daysDiff(aStart, aEnd);
- const bDuration = daysDiff(bStart, bEnd);
- if (bDuration !== aDuration) return bDuration - aDuration;
- return aStart.getTime() - bStart.getTime();
+ if (b.durationMs !== a.durationMs) return b.durationMs - a.durationMs;
+ return a.startMs - b.startMs;
});
// Lane assignment - track which days are occupied in each lane
const lanes: boolean[][] = [];
- for (const entity of multiDayEvents) {
- const eventStart = startOfDay(new Date(entity.properties.startsOn));
- const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
+ for (const occurrence of multiDayEvents) {
+ const eventStart = startOfDay(new Date(occurrence.startMs));
+ const eventEnd = startOfDay(new Date(occurrence.endMs - 1));
// Clamp to visible range
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
@@ -156,7 +156,7 @@ export function getMultiDaySegments(
}
segments.push({
- entity,
+ occurrence,
startCol,
span,
lane,
diff --git a/src/utils/occurrenceIndex.ts b/src/utils/occurrenceIndex.ts
new file mode 100644
index 0000000..bf78d53
--- /dev/null
+++ b/src/utils/occurrenceIndex.ts
@@ -0,0 +1,117 @@
+import type { EntityObject } from '@ChronoManager/models/entity'
+import type { EventObject } from '@ChronoManager/models/event'
+import type {
+ CalendarOccurrence,
+ CalendarOccurrenceIndex,
+} from '../types/occurrence'
+import type { VisibleDateRange } from '../types'
+import { shiftLocalDays, startOfLocalDay } from './dateRanges'
+
+const MINIMUM_DURATION_MS = 1
+
+function eventTimestamp(value: string | null | undefined): number | null {
+ if (!value) return null
+
+ const timestamp = Date.parse(value)
+ return Number.isFinite(timestamp) ? timestamp : null
+}
+
+function occurrenceFromEntity(entity: EntityObject): CalendarOccurrence | null {
+ const event = entity.properties as EventObject
+ const startMs = eventTimestamp(event.startsOn)
+ if (startMs === null) return null
+
+ const parsedEndMs = eventTimestamp(event.endsOn)
+ const endMs = parsedEndMs !== null && parsedEndMs > startMs
+ ? parsedEndMs
+ : startMs + MINIMUM_DURATION_MS
+ const dayStartMs = startOfLocalDay(new Date(startMs)).getTime()
+ const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).getTime()
+ const entityKey = String(entity.identifier)
+
+ return {
+ key: `${entityKey}:master`,
+ entity,
+ occurrenceId: null,
+ startMs,
+ endMs,
+ dayStartMs,
+ durationMs: endMs - startMs,
+ timeless: event.timeless === true || lastOccupiedDayMs !== dayStartMs,
+ multiDay: lastOccupiedDayMs !== dayStartMs,
+ }
+}
+
+function appendToDayBuckets(index: CalendarOccurrenceIndex, occurrence: CalendarOccurrence) {
+ const lastOccupiedDayMs = startOfLocalDay(new Date(occurrence.endMs - 1)).getTime()
+ let day = new Date(occurrence.dayStartMs)
+
+ while (day.getTime() <= lastOccupiedDayMs) {
+ const dayKey = day.getTime()
+ const bucket = index.byDay.get(dayKey)
+ if (bucket) bucket.push(occurrence)
+ else index.byDay.set(dayKey, [occurrence])
+ day = startOfLocalDay(shiftLocalDays(day, 1))
+ }
+}
+
+function sortOccurrences(occurrences: CalendarOccurrence[]) {
+ occurrences.sort((left, right) => (
+ left.startMs - right.startMs
+ || right.durationMs - left.durationMs
+ || left.key.localeCompare(right.key)
+ ))
+}
+
+export function buildOccurrenceIndex(entities: EntityObject[]): CalendarOccurrenceIndex {
+ const index: CalendarOccurrenceIndex = {
+ byDay: new Map(),
+ byEntity: new Map(),
+ sorted: [],
+ }
+
+ for (const entity of entities) {
+ const occurrence = occurrenceFromEntity(entity)
+ if (!occurrence) continue
+
+ index.sorted.push(occurrence)
+ index.byEntity.set(String(entity.identifier), [occurrence])
+ appendToDayBuckets(index, occurrence)
+ }
+
+ sortOccurrences(index.sorted)
+ for (const bucket of index.byDay.values()) sortOccurrences(bucket)
+
+ return index
+}
+
+export function occurrencesForDay(
+ index: CalendarOccurrenceIndex,
+ date: Date | number,
+): CalendarOccurrence[] {
+ const dayKey = startOfLocalDay(new Date(date)).getTime()
+ return index.byDay.get(dayKey) ?? []
+}
+
+export function occurrencesForRange(
+ index: CalendarOccurrenceIndex,
+ range: VisibleDateRange,
+): CalendarOccurrence[] {
+ if (range.endMs <= range.startMs) return []
+
+ const matches = new Map()
+ 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
+}
diff --git a/tests/js/unit/occurrenceIndex.test.ts b/tests/js/unit/occurrenceIndex.test.ts
new file mode 100644
index 0000000..e2ed72c
--- /dev/null
+++ b/tests/js/unit/occurrenceIndex.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest'
+import type { EntityObject } from '../../../../chrono_manager/src/models/entity'
+import {
+ buildOccurrenceIndex,
+ occurrencesForDay,
+ occurrencesForRange,
+} from '../../../src/utils/occurrenceIndex'
+import { daysVisibleRange } from '../../../src/utils/dateRanges'
+
+function localIso(year: number, month: number, day: number, hour = 0): string {
+ return new Date(year, month, day, hour).toISOString()
+}
+
+function eventEntity(
+ identifier: string,
+ startsOn: string | null,
+ endsOn: string | null,
+ timeless = false,
+): EntityObject {
+ return {
+ identifier,
+ properties: {
+ startsOn,
+ endsOn,
+ timeless,
+ },
+ } as unknown as EntityObject
+}
+
+describe('occurrence index', () => {
+ it('normalizes and sorts event timestamps once', () => {
+ const later = eventEntity('later', localIso(2026, 5, 15, 13), localIso(2026, 5, 15, 14))
+ const earlier = eventEntity('earlier', localIso(2026, 5, 15, 9), localIso(2026, 5, 15, 10))
+ const invalid = eventEntity('invalid', 'not-a-date', null)
+ const index = buildOccurrenceIndex([later, invalid, earlier])
+
+ expect(index.sorted.map(occurrence => occurrence.entity.identifier)).toEqual(['earlier', 'later'])
+ expect(index.byEntity.get('earlier')?.[0]?.durationMs).toBe(60 * 60 * 1000)
+ expect(index.byEntity.has('invalid')).toBe(false)
+ })
+
+ it('places an overlapping event in each occupied day bucket', () => {
+ const overnight = eventEntity(
+ 'overnight',
+ localIso(2026, 5, 15, 23),
+ localIso(2026, 5, 16, 1),
+ )
+ const index = buildOccurrenceIndex([overnight])
+
+ expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1)
+ expect(occurrencesForDay(index, new Date(2026, 5, 16))).toHaveLength(1)
+ expect(index.sorted[0].multiDay).toBe(true)
+ expect(index.sorted[0].timeless).toBe(true)
+ })
+
+ it('honors an exclusive midnight end', () => {
+ const allDay = eventEntity(
+ 'all-day',
+ localIso(2026, 5, 20),
+ localIso(2026, 5, 21),
+ true,
+ )
+ const index = buildOccurrenceIndex([allDay])
+
+ expect(occurrencesForDay(index, new Date(2026, 5, 20))).toHaveLength(1)
+ expect(occurrencesForDay(index, new Date(2026, 5, 21))).toHaveLength(0)
+ })
+
+ it('retrieves overlapping occurrences without duplicates', () => {
+ const spanning = eventEntity(
+ 'spanning',
+ localIso(2026, 5, 14),
+ localIso(2026, 5, 18),
+ true,
+ )
+ const outside = eventEntity('outside', localIso(2026, 6, 1), localIso(2026, 6, 1, 1))
+ const index = buildOccurrenceIndex([outside, spanning])
+ const occurrences = occurrencesForRange(index, daysVisibleRange(new Date(2026, 5, 15), 2))
+
+ expect(occurrences.map(occurrence => occurrence.entity.identifier)).toEqual(['spanning'])
+ })
+
+ it('gives a zero-duration event a queryable minimum duration', () => {
+ const instant = eventEntity('instant', localIso(2026, 5, 15, 9), null)
+ const index = buildOccurrenceIndex([instant])
+
+ expect(index.sorted[0].durationMs).toBe(1)
+ expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1)
+ })
+})