@@ -101,17 +101,13 @@
@@ -211,15 +208,15 @@ function getEventColor(occurrence: CalendarOccurrence): string {
- {{ occurrence.label || 'Untitled' }}
+ {{ instance.label || 'Untitled' }}
- {{ segment.occurrence.label || 'Untitled' }}
+ {{ segment.instance.label || 'Untitled' }}
diff --git a/src/components/editors/EventEditorDates.vue b/src/components/editors/EventEditorDates.vue
index c39e8a6..e04c4e4 100644
--- a/src/components/editors/EventEditorDates.vue
+++ b/src/components/editors/EventEditorDates.vue
@@ -1,5 +1,6 @@
diff --git a/src/pages/ChronoPage.vue b/src/pages/ChronoPage.vue
index c5eb8e6..eae1083 100644
--- a/src/pages/ChronoPage.vue
+++ b/src/pages/ChronoPage.vue
@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { useDisplay } from 'vuetify';
import { useModuleStore } from '@KTXC/stores/moduleStore';
+import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
import { useChronoUiStore } from '@/stores/chronoUiStore';
@@ -28,6 +29,7 @@ const isChronoManagerAvailable = computed(() => {
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
});
+const chronoInstancesStore = useChronoInstancesStore();
const chronoOperationsStore = useChronoOperationsStore();
const chronoSettingsStore = useChronoSettingsStore();
const chronoUiStore = useChronoUiStore();
@@ -45,9 +47,10 @@ const {
taskLists,
filteredTasks,
collections,
- occurrenceIndex,
} = storeToRefs(chronoOperationsStore);
+const { setVisibleRange } = chronoInstancesStore;
+
const {
currentDate,
sidebarVisible,
@@ -91,7 +94,6 @@ const {
} = chronoUiStore;
const {
- setVisibleRange,
toggleCalendarVisibility,
toggleTaskComplete,
} = chronoOperationsStore;
@@ -277,7 +279,6 @@ watch(() => route.fullPath, () => {
v-if="!isTaskView"
:view="calendarView"
:current-date="currentDate"
- :occurrence-index="occurrenceIndex"
:calendars="calendars"
:initial-days-span="daysViewSpan"
:initial-agenda-view-span="agendaViewSpan"
diff --git a/src/stores/chronoInstancesStore.ts b/src/stores/chronoInstancesStore.ts
new file mode 100644
index 0000000..444b91a
--- /dev/null
+++ b/src/stores/chronoInstancesStore.ts
@@ -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
+
+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(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()
+ let expansionCacheRange: VisibleDateRange | null = null
+
+ const instancesByDay = computed(() => {
+ const range = visibleRange.value
+ if (
+ expansionCacheRange?.startMs !== range?.startMs
+ || expansionCacheRange?.endMs !== range?.endMs
+ ) {
+ expansionCache.clear()
+ expansionCacheRange = range
+ }
+
+ const present = new Set()
+ 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()
+ 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,
+ }
+})
diff --git a/src/stores/chronoOperationsStore.ts b/src/stores/chronoOperationsStore.ts
index e918153..5d164d6 100644
--- a/src/stores/chronoOperationsStore.ts
+++ b/src/stores/chronoOperationsStore.ts
@@ -1,4 +1,4 @@
-import { computed, ref, shallowRef } from 'vue'
+import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { CollectionObject } from '@ChronoManager/models/collection'
import { EntityObject } from '@ChronoManager/models/entity'
@@ -9,8 +9,6 @@ import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
import { useImportStore } from '@ChronoManager/stores/importStore'
-import type { VisibleDateRange } from '@/types'
-import { buildOccurrenceIndex, occurrencesForRange } from '@/utils/occurrenceIndex'
type ChronoEntityProperties = EventObject | TaskObject
@@ -21,7 +19,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
const importStore = useImportStore()
const loading = ref(false)
- const visibleRange = shallowRef(null)
const collections = computed(() => collectionsStore.collections)
const entities = computed(() => entitiesStore.entities)
@@ -45,24 +42,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
})
const filteredTasks = computed(() => tasks.value)
- const occurrenceIndex = computed(() => buildOccurrenceIndex(
- filteredEvents.value,
- visibleRange.value ?? undefined,
- ))
- const visibleOccurrences = computed(() => visibleRange.value
- ? occurrencesForRange(occurrenceIndex.value, visibleRange.value)
- : [])
-
- function setVisibleRange(range: VisibleDateRange) {
- if (
- visibleRange.value?.startMs === range.startMs
- && visibleRange.value.endMs === range.endMs
- ) {
- return
- }
-
- visibleRange.value = { ...range }
- }
function prepareEntityProperties(entity: EntityObject): ChronoEntityProperties {
const properties = entity.properties as ChronoEntityProperties
@@ -175,7 +154,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
return {
loading,
- visibleRange,
collections,
entities,
calendars,
@@ -184,9 +162,6 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
tasks,
filteredEvents,
filteredTasks,
- occurrenceIndex,
- visibleOccurrences,
- setVisibleRange,
saveEntity,
deleteEntity,
toggleCalendarVisibility,
diff --git a/src/types/occurrence.ts b/src/types/instance.ts
similarity index 53%
rename from src/types/occurrence.ts
rename to src/types/instance.ts
index 6e0c314..185e081 100644
--- a/src/types/occurrence.ts
+++ b/src/types/instance.ts
@@ -1,9 +1,9 @@
import type { EntityObject } from '@ChronoManager/models/entity'
-export interface CalendarOccurrence {
+export interface CalendarInstance {
key: string
entity: EntityObject
- occurrenceId: string | null
+ instanceId: string | null
startMs: number
endMs: number
dayStartMs: number
@@ -13,9 +13,3 @@ export interface CalendarOccurrence {
label: string | null
color: string | null
}
-
-export interface CalendarOccurrenceIndex {
- byDay: Map
- byEntity: Map
- sorted: CalendarOccurrence[]
-}
diff --git a/src/utils/calendarHelpers.ts b/src/utils/calendarHelpers.ts
deleted file mode 100644
index 49110ae..0000000
--- a/src/utils/calendarHelpers.ts
+++ /dev/null
@@ -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();
- });
-}
diff --git a/src/utils/dateRanges.ts b/src/utils/date.ts
similarity index 77%
rename from src/utils/dateRanges.ts
rename to src/utils/date.ts
index dc200d9..4e880cc 100644
--- a/src/utils/dateRanges.ts
+++ b/src/utils/date.ts
@@ -1,5 +1,21 @@
import type { VisibleDateRange } from '../types'
+export function parseCalendarDate(value: string | null | undefined): Date | null {
+ if (!value) return null
+
+ const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
+ if (!parts) return null
+
+ const year = Number(parts[1])
+ const month = Number(parts[2]) - 1
+ const day = Number(parts[3])
+ const date = new Date(year, month, day)
+
+ return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day
+ ? date
+ : null
+}
+
export function startOfLocalDay(date: Date): Date {
const start = new Date(date)
start.setHours(0, 0, 0, 0)
diff --git a/src/utils/multiDayLanes.ts b/src/utils/multiDayLanes.ts
new file mode 100644
index 0000000..c7c0e31
--- /dev/null
+++ b/src/utils/multiDayLanes.ts
@@ -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 };
+}
diff --git a/src/utils/occurrenceIndex.ts b/src/utils/occurrenceIndex.ts
deleted file mode 100644
index df1388d..0000000
--- a/src/utils/occurrenceIndex.ts
+++ /dev/null
@@ -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()
- 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/src/utils/recurrence.ts b/src/utils/recurrence.ts
index 806fa05..dedde3a 100644
--- a/src/utils/recurrence.ts
+++ b/src/utils/recurrence.ts
@@ -1,9 +1,9 @@
import type { EntityObject } from '@ChronoManager/models/entity'
import type { EventObject } from '@ChronoManager/models/event'
import type { EventMutationObject } from '@ChronoManager/models/event-mutation'
-import type { CalendarOccurrence } from '../types/occurrence'
+import type { CalendarInstance } from '../types/instance'
import type { VisibleDateRange } from '../types'
-import { shiftLocalDays, startOfLocalDay } from './dateRanges'
+import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date'
const MINIMUM_DURATION_MS = 1
@@ -13,6 +13,14 @@ function timestamp(value: string | null | undefined): number | null {
return Number.isFinite(result) ? result : null
}
+function floatingDateTimestamp(value: string | null | undefined): number | null {
+ return parseCalendarDate(value)?.getTime() ?? null
+}
+
+function eventTimestamp(value: string | null | undefined, timeless: boolean): number | null {
+ return timeless ? floatingDateTimestamp(value) : timestamp(value)
+}
+
function conclusionTimestamp(value: string | null | undefined): number | null {
if (!value) return null
@@ -36,63 +44,79 @@ function overlapsRange(startMs: number, endMs: number, range?: VisibleDateRange)
return !range || (startMs < range.endMs && endMs > range.startMs)
}
-function createOccurrence(
+function createInstance(
entity: EntityObject,
sourceStartMs: number,
effectiveStartMs: number,
effectiveEndMs: number,
recurring: boolean,
+ timeless: boolean,
mutation?: EventMutationObject,
-): CalendarOccurrence {
+): CalendarInstance {
const event = entity.properties as EventObject
const endMs = effectiveEndMs > effectiveStartMs
? effectiveEndMs
: effectiveStartMs + MINIMUM_DURATION_MS
const dayStartMs = startOfLocalDay(new Date(effectiveStartMs)).getTime()
const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).getTime()
- const occurrenceId = recurring ? new Date(sourceStartMs).toISOString() : null
+ const instanceId = recurring ? new Date(sourceStartMs).toISOString() : null
return {
- key: `${String(entity.identifier)}:${occurrenceId ?? 'master'}`,
+ key: `${String(entity.identifier)}:${instanceId ?? 'master'}`,
entity,
- occurrenceId,
+ instanceId,
startMs: effectiveStartMs,
endMs,
dayStartMs,
durationMs: endMs - effectiveStartMs,
- timeless: (mutation?.timeless ?? event.timeless === true) || lastOccupiedDayMs !== dayStartMs,
+ timeless: timeless || lastOccupiedDayMs !== dayStartMs,
multiDay: lastOccupiedDayMs !== dayStartMs,
label: mutation?.label ?? event.label,
color: mutation?.color ?? event.color,
}
}
-function mutationMap(event: EventObject): Map {
+function mutationMap(event: EventObject, timeless: boolean): Map {
const mutations = new Map()
for (const [key, mutation] of Object.entries(event.mutations ?? {})) {
- const mutationId = timestamp(mutation.mutationId) ?? timestamp(key)
+ const mutationId = eventTimestamp(mutation.mutationId, timeless)
+ ?? eventTimestamp(key, timeless)
if (mutationId !== null) mutations.set(mutationId, mutation)
}
return mutations
}
-function materializeOccurrence(
+function materializeInstance(
entity: EntityObject,
sourceStartMs: number,
durationMs: number,
+ durationDays: number | null,
recurring: boolean,
mutation: EventMutationObject | undefined,
range?: VisibleDateRange,
-): CalendarOccurrence | null {
+): CalendarInstance | null {
if (mutation?.mutationExclusion === true) return null
- const effectiveStartMs = timestamp(mutation?.startsOn) ?? sourceStartMs
- const effectiveEndMs = timestamp(mutation?.endsOn) ?? effectiveStartMs + durationMs
+ const event = entity.properties as EventObject
+ const timeless = mutation?.timeless ?? event.timeless === true
+ const effectiveStartMs = eventTimestamp(mutation?.startsOn, timeless) ?? sourceStartMs
+ const defaultEndMs = timeless && durationDays !== null
+ ? shiftLocalDays(new Date(effectiveStartMs), durationDays).getTime()
+ : effectiveStartMs + durationMs
+ const effectiveEndMs = eventTimestamp(mutation?.endsOn, timeless) ?? defaultEndMs
if (!overlapsRange(effectiveStartMs, effectiveEndMs, range)) return null
- return createOccurrence(entity, sourceStartMs, effectiveStartMs, effectiveEndMs, recurring, mutation)
+ return createInstance(
+ entity,
+ sourceStartMs,
+ effectiveStartMs,
+ effectiveEndMs,
+ recurring,
+ timeless,
+ mutation,
+ )
}
function localDayNumber(date: Date): number {
@@ -131,9 +155,10 @@ function expandDaily(
entity: EntityObject,
start: Date,
durationMs: number,
+ durationDays: number | null,
range: VisibleDateRange,
mutations: Map,
-): CalendarOccurrence[] {
+): CalendarInstance[] {
const pattern = (entity.properties as EventObject).pattern!
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
@@ -147,7 +172,7 @@ function expandDaily(
? dailyStartIndex(start, interval, durationMs, range)
: 0
let occurrenceCount = weekdays ? 0 : step
- const occurrences: CalendarOccurrence[] = []
+ const instances: CalendarInstance[] = []
while (true) {
const candidate = shiftLocalDays(start, step * interval)
@@ -159,30 +184,32 @@ function expandDaily(
occurrenceCount += 1
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break
- const occurrence = materializeOccurrence(
+ const instance = materializeInstance(
entity,
candidateMs,
durationMs,
+ durationDays,
true,
mutations.get(candidateMs),
range,
)
- if (occurrence) occurrences.push(occurrence)
+ if (instance) instances.push(instance)
}
step += 1
}
- return occurrences
+ return instances
}
function expandWeekly(
entity: EntityObject,
start: Date,
durationMs: number,
+ durationDays: number | null,
range: VisibleDateRange,
mutations: Map,
-): CalendarOccurrence[] {
+): CalendarInstance[] {
const pattern = (entity.properties as EventObject).pattern!
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
@@ -195,7 +222,7 @@ function expandWeekly(
? weeklyStartIndex(weekStart, interval, durationMs, range)
: 0
let occurrenceCount = maximumOccurrences === null ? week * weekdays.length : 0
- const occurrences: CalendarOccurrence[] = []
+ const instances: CalendarInstance[] = []
while (true) {
const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7)
@@ -205,79 +232,94 @@ function expandWeekly(
const candidate = shiftLocalDays(intervalWeekStart, weekday - 1)
const candidateMs = candidate.getTime()
if (candidateMs < start.getTime()) continue
- if (candidateMs >= range.endMs) return occurrences
- if (concludesMs !== null && candidateMs > concludesMs) return occurrences
+ if (candidateMs >= range.endMs) return instances
+ if (concludesMs !== null && candidateMs > concludesMs) return instances
occurrenceCount += 1
- if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return occurrences
+ if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return instances
- const occurrence = materializeOccurrence(
+ const instance = materializeInstance(
entity,
candidateMs,
durationMs,
+ durationDays,
true,
mutations.get(candidateMs),
range,
)
- if (occurrence) occurrences.push(occurrence)
+ if (instance) instances.push(instance)
}
week += 1
}
- return occurrences
+ return instances
}
function appendMovedMutations(
- occurrences: CalendarOccurrence[],
+ instances: CalendarInstance[],
entity: EntityObject,
durationMs: number,
+ durationDays: number | null,
range: VisibleDateRange,
mutations: Map,
) {
- const present = new Set(occurrences.map(occurrence => occurrence.key))
+ const present = new Set(instances.map(instance => instance.key))
for (const [sourceStartMs, mutation] of mutations) {
- const occurrence = materializeOccurrence(
+ const instance = materializeInstance(
entity,
sourceStartMs,
durationMs,
+ durationDays,
true,
mutation,
range,
)
- if (occurrence && !present.has(occurrence.key)) {
- occurrences.push(occurrence)
- present.add(occurrence.key)
+ if (instance && !present.has(instance.key)) {
+ instances.push(instance)
+ present.add(instance.key)
}
}
}
-export function expandEntityOccurrences(
+export function expandEntityInstances(
entity: EntityObject,
range?: VisibleDateRange,
-): CalendarOccurrence[] {
+): CalendarInstance[] {
const event = entity.properties as EventObject
- const startMs = timestamp(event.startsOn)
+ const timeless = event.timeless === true
+ const startMs = eventTimestamp(event.startsOn, timeless)
if (startMs === null) return []
- const parsedEndMs = timestamp(event.endsOn)
+ const parsedEndMs = eventTimestamp(event.endsOn, timeless)
const durationMs = parsedEndMs !== null && parsedEndMs > startMs
? parsedEndMs - startMs
: MINIMUM_DURATION_MS
+ const durationDays = timeless && parsedEndMs !== null && parsedEndMs > startMs
+ ? Math.max(1, localDayNumber(new Date(parsedEndMs)) - localDayNumber(new Date(startMs)))
+ : null
const pattern = event.pattern
if (!pattern || !range || (pattern.precision !== 'daily' && pattern.precision !== 'weekly')) {
- const occurrence = materializeOccurrence(entity, startMs, durationMs, false, undefined, range)
- return occurrence ? [occurrence] : []
+ const instance = materializeInstance(
+ entity,
+ startMs,
+ durationMs,
+ durationDays,
+ false,
+ undefined,
+ range,
+ )
+ return instance ? [instance] : []
}
- const mutations = mutationMap(event)
- const occurrences = pattern.precision === 'daily'
- ? expandDaily(entity, new Date(startMs), durationMs, range, mutations)
- : expandWeekly(entity, new Date(startMs), durationMs, range, mutations)
+ const mutations = mutationMap(event, timeless)
+ const instances = pattern.precision === 'daily'
+ ? expandDaily(entity, new Date(startMs), durationMs, durationDays, range, mutations)
+ : expandWeekly(entity, new Date(startMs), durationMs, durationDays, range, mutations)
- appendMovedMutations(occurrences, entity, durationMs, range, mutations)
- occurrences.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
- return occurrences
+ appendMovedMutations(instances, entity, durationMs, durationDays, range, mutations)
+ instances.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
+ return instances
}
diff --git a/tests/js/unit/occurrenceIndex.test.ts b/tests/js/unit/occurrenceIndex.test.ts
index e2ed72c..4dd7c75 100644
--- a/tests/js/unit/occurrenceIndex.test.ts
+++ b/tests/js/unit/occurrenceIndex.test.ts
@@ -66,6 +66,21 @@ describe('occurrence index', () => {
expect(occurrencesForDay(index, new Date(2026, 5, 21))).toHaveLength(0)
})
+ it('treats UTC timestamps on timeless events as floating calendar dates', () => {
+ const allDay = eventEntity(
+ 'utc-all-day',
+ '2025-03-27T00:00:00+00:00',
+ '2025-03-28T00:00:00+00:00',
+ true,
+ )
+ const index = buildOccurrenceIndex([allDay])
+
+ expect(occurrencesForDay(index, new Date(2025, 2, 26))).toHaveLength(0)
+ expect(occurrencesForDay(index, new Date(2025, 2, 27))).toHaveLength(1)
+ expect(occurrencesForDay(index, new Date(2025, 2, 28))).toHaveLength(0)
+ expect(new Date(index.sorted[0].startMs).getHours()).toBe(0)
+ })
+
it('retrieves overlapping occurrences without duplicates', () => {
const spanning = eventEntity(
'spanning',
diff --git a/tests/js/unit/recurrence.test.ts b/tests/js/unit/recurrence.test.ts
index 64ae3c5..c870ceb 100644
--- a/tests/js/unit/recurrence.test.ts
+++ b/tests/js/unit/recurrence.test.ts
@@ -87,6 +87,32 @@ describe('daily recurrence', () => {
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([9, 9, 9])
})
+
+ it('preserves exclusive calendar-day duration for recurring timeless events', () => {
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'daily',
+ interval: 1,
+ }, {}, '2026-03-07T00:00:00+00:00')
+ const event = entity.properties as unknown as {
+ endsOn: string
+ timeless: boolean
+ }
+ event.endsOn = '2026-03-08T00:00:00+00:00'
+ event.timeless = true
+
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 2, 7), 3),
+ )
+
+ expect(occurrences).toHaveLength(3)
+ expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([0, 0, 0])
+ expect(occurrences.map(occurrence => {
+ const end = new Date(occurrence.endMs)
+ return [end.getDate(), end.getHours()]
+ })).toEqual([[8, 0], [9, 0], [10, 0]])
+ })
})
describe('weekly recurrence', () => {