diff --git a/src/components/AgendaView.vue b/src/components/AgendaView.vue
index dc06740..402cd94 100644
--- a/src/components/AgendaView.vue
+++ b/src/components/AgendaView.vue
@@ -37,7 +37,7 @@
mdi-circle
- {{ occurrence.entity.properties?.label || 'Untitled' }}
+ {{ occurrence.label || 'Untitled' }}
{{ occurrence.timeless ? 'All day' : `${formatOccurrenceTime(occurrence.startMs)} - ${formatOccurrenceTime(occurrence.endMs)}` }}
@@ -52,7 +52,6 @@ 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 { EventObject } from '@ChronoManager/models/event';
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
import { occurrencesForRange } from '@/utils/occurrenceIndex';
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
@@ -149,16 +148,11 @@ const groupedEvents = computed(() => {
function getEventColor(occurrence: CalendarOccurrence): string {
const entity = occurrence.entity;
- const event = getEventProperties(entity);
- if (event.color) return event.color;
+ if (occurrence.color) return occurrence.color;
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
return calendar?.properties?.color || '#1976D2';
}
-function getEventProperties(entity: CalendarEntity): EventObject {
- return entity.properties as EventObject;
-}
-
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
diff --git a/src/components/DaysView.vue b/src/components/DaysView.vue
index de1369f..5eaab97 100644
--- a/src/components/DaysView.vue
+++ b/src/components/DaysView.vue
@@ -56,7 +56,7 @@
@mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
@mouseleave="emit('event-hover-end')"
>
- {{ segment.occurrence.entity.properties?.label || 'Untitled' }}
+ {{ segment.occurrence.label || 'Untitled' }}
@@ -85,10 +85,10 @@
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
- {{ getEventProperties(occurrence.entity).label || 'Untitled' }}
+ {{ occurrence.label || 'Untitled' }}
- {{ getEventProperties(occurrence.entity).label || 'Untitled' }}
+ {{ occurrence.label || 'Untitled' }}
@@ -110,7 +110,6 @@ import { shiftLocalDays } from '@/utils/dateRanges';
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';
@@ -223,20 +222,15 @@ function getTimedEvents(date: Date): CalendarOccurrence[] {
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
}
-function getEventProperties(entity: CalendarEntity): EventObject {
- return entity.properties as EventObject;
-}
-
-function getEventColor(entity: CalendarEntity): string {
- const event = getEventProperties(entity);
- if (event.color) return event.color;
- const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
+function getEventColor(occurrence: CalendarOccurrence): string {
+ if (occurrence.color) return occurrence.color;
+ const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
return calendar?.properties?.color || '#1976D2';
}
function getAllDayEventStyle(segment: MultiDaySegment) {
return {
- backgroundColor: getEventColor(segment.occurrence.entity),
+ backgroundColor: getEventColor(segment.occurrence),
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
@@ -251,7 +245,7 @@ function getEventStyle(occurrence: CalendarOccurrence) {
return {
top: `${(start / 1440) * 100}%`,
height: `${(duration / 1440) * 100}%`,
- backgroundColor: getEventColor(occurrence.entity),
+ backgroundColor: getEventColor(occurrence),
};
}
diff --git a/src/components/MonthView.vue b/src/components/MonthView.vue
index cf71607..afcd2b2 100644
--- a/src/components/MonthView.vue
+++ b/src/components/MonthView.vue
@@ -161,9 +161,9 @@ function isToday(date: Date): boolean {
return date.toDateString() === today.toDateString();
}
-function getEventColor(entity: CalendarOccurrence['entity']): string {
- if (entity.properties?.color) return entity.properties.color;
- const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
+function getEventColor(occurrence: CalendarOccurrence): string {
+ if (occurrence.color) return occurrence.color;
+ const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
return calendar?.properties?.color || '#1976D2';
}
@@ -214,12 +214,12 @@ function getEventColor(entity: CalendarOccurrence['entity']): string {
v-for="occurrence in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
:key="occurrence.key"
class="single-day-event"
- :style="{ backgroundColor: getEventColor(occurrence.entity) }"
+ :style="{ backgroundColor: getEventColor(occurrence) }"
@click.stop="$emit('event-click', occurrence.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: occurrence.entity })"
@mouseleave="$emit('event-hover-end')"
>
- {{ occurrence.entity.properties?.label || 'Untitled' }}
+ {{ occurrence.label || 'Untitled' }}
- {{ segment.occurrence.entity.properties?.label || 'Untitled' }}
+ {{ segment.occurrence.label || 'Untitled' }}
diff --git a/src/stores/chronoOperationsStore.ts b/src/stores/chronoOperationsStore.ts
index 0b04980..e918153 100644
--- a/src/stores/chronoOperationsStore.ts
+++ b/src/stores/chronoOperationsStore.ts
@@ -45,7 +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 occurrenceIndex = computed(() => buildOccurrenceIndex(
+ filteredEvents.value,
+ visibleRange.value ?? undefined,
+ ))
const visibleOccurrences = computed(() => visibleRange.value
? occurrencesForRange(occurrenceIndex.value, visibleRange.value)
: [])
diff --git a/src/types/occurrence.ts b/src/types/occurrence.ts
index 8b20986..6e0c314 100644
--- a/src/types/occurrence.ts
+++ b/src/types/occurrence.ts
@@ -10,6 +10,8 @@ export interface CalendarOccurrence {
durationMs: number
timeless: boolean
multiDay: boolean
+ label: string | null
+ color: string | null
}
export interface CalendarOccurrenceIndex {
diff --git a/src/utils/occurrenceIndex.ts b/src/utils/occurrenceIndex.ts
index bf78d53..df1388d 100644
--- a/src/utils/occurrenceIndex.ts
+++ b/src/utils/occurrenceIndex.ts
@@ -1,46 +1,11 @@
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,
- }
-}
+import { expandEntityOccurrences } from './recurrence'
function appendToDayBuckets(index: CalendarOccurrenceIndex, occurrence: CalendarOccurrence) {
const lastOccupiedDayMs = startOfLocalDay(new Date(occurrence.endMs - 1)).getTime()
@@ -63,7 +28,10 @@ function sortOccurrences(occurrences: CalendarOccurrence[]) {
))
}
-export function buildOccurrenceIndex(entities: EntityObject[]): CalendarOccurrenceIndex {
+export function buildOccurrenceIndex(
+ entities: EntityObject[],
+ range?: VisibleDateRange,
+): CalendarOccurrenceIndex {
const index: CalendarOccurrenceIndex = {
byDay: new Map(),
byEntity: new Map(),
@@ -71,12 +39,14 @@ export function buildOccurrenceIndex(entities: EntityObject[]): CalendarOccurren
}
for (const entity of entities) {
- const occurrence = occurrenceFromEntity(entity)
- if (!occurrence) continue
+ const entityOccurrences = expandEntityOccurrences(entity, range)
+ if (entityOccurrences.length === 0) continue
- index.sorted.push(occurrence)
- index.byEntity.set(String(entity.identifier), [occurrence])
- appendToDayBuckets(index, occurrence)
+ index.byEntity.set(String(entity.identifier), entityOccurrences)
+ for (const occurrence of entityOccurrences) {
+ index.sorted.push(occurrence)
+ appendToDayBuckets(index, occurrence)
+ }
}
sortOccurrences(index.sorted)
diff --git a/src/utils/recurrence.ts b/src/utils/recurrence.ts
new file mode 100644
index 0000000..806fa05
--- /dev/null
+++ b/src/utils/recurrence.ts
@@ -0,0 +1,283 @@
+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 { VisibleDateRange } from '../types'
+import { shiftLocalDays, startOfLocalDay } from './dateRanges'
+
+const MINIMUM_DURATION_MS = 1
+
+function timestamp(value: string | null | undefined): number | null {
+ if (!value) return null
+ const result = Date.parse(value)
+ return Number.isFinite(result) ? result : null
+}
+
+function conclusionTimestamp(value: string | null | undefined): number | null {
+ if (!value) return null
+
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
+ if (dateOnly) {
+ return new Date(
+ Number(dateOnly[1]),
+ Number(dateOnly[2]) - 1,
+ Number(dateOnly[3]),
+ 23,
+ 59,
+ 59,
+ 999,
+ ).getTime()
+ }
+
+ return timestamp(value)
+}
+
+function overlapsRange(startMs: number, endMs: number, range?: VisibleDateRange): boolean {
+ return !range || (startMs < range.endMs && endMs > range.startMs)
+}
+
+function createOccurrence(
+ entity: EntityObject,
+ sourceStartMs: number,
+ effectiveStartMs: number,
+ effectiveEndMs: number,
+ recurring: boolean,
+ mutation?: EventMutationObject,
+): CalendarOccurrence {
+ 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
+
+ return {
+ key: `${String(entity.identifier)}:${occurrenceId ?? 'master'}`,
+ entity,
+ occurrenceId,
+ startMs: effectiveStartMs,
+ endMs,
+ dayStartMs,
+ durationMs: endMs - effectiveStartMs,
+ timeless: (mutation?.timeless ?? event.timeless === true) || lastOccupiedDayMs !== dayStartMs,
+ multiDay: lastOccupiedDayMs !== dayStartMs,
+ label: mutation?.label ?? event.label,
+ color: mutation?.color ?? event.color,
+ }
+}
+
+function mutationMap(event: EventObject): Map {
+ const mutations = new Map()
+
+ for (const [key, mutation] of Object.entries(event.mutations ?? {})) {
+ const mutationId = timestamp(mutation.mutationId) ?? timestamp(key)
+ if (mutationId !== null) mutations.set(mutationId, mutation)
+ }
+
+ return mutations
+}
+
+function materializeOccurrence(
+ entity: EntityObject,
+ sourceStartMs: number,
+ durationMs: number,
+ recurring: boolean,
+ mutation: EventMutationObject | undefined,
+ range?: VisibleDateRange,
+): CalendarOccurrence | null {
+ if (mutation?.mutationExclusion === true) return null
+
+ const effectiveStartMs = timestamp(mutation?.startsOn) ?? sourceStartMs
+ const effectiveEndMs = timestamp(mutation?.endsOn) ?? effectiveStartMs + durationMs
+ if (!overlapsRange(effectiveStartMs, effectiveEndMs, range)) return null
+
+ return createOccurrence(entity, sourceStartMs, effectiveStartMs, effectiveEndMs, recurring, mutation)
+}
+
+function localDayNumber(date: Date): number {
+ return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
+}
+
+function dailyStartIndex(
+ start: Date,
+ interval: number,
+ durationMs: number,
+ range: VisibleDateRange,
+): number {
+ const earliestRelevant = new Date(range.startMs - durationMs)
+ const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(start)
+ return Math.max(0, Math.floor(elapsedDays / interval) - 1)
+}
+
+function weeklyStartIndex(
+ weekStart: Date,
+ interval: number,
+ durationMs: number,
+ range: VisibleDateRange,
+): number {
+ const earliestRelevant = new Date(range.startMs - durationMs)
+ const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(weekStart)
+ return Math.max(0, Math.floor(elapsedDays / (interval * 7)) - 1)
+}
+
+function normalizedWeekdays(values: number[] | null, fallback: number): number[] {
+ const weekdays = (values?.length ? values : [fallback])
+ .filter(value => Number.isInteger(value) && value >= 1 && value <= 7)
+ return Array.from(new Set(weekdays)).sort((left, right) => left - right)
+}
+
+function expandDaily(
+ entity: EntityObject,
+ start: Date,
+ durationMs: number,
+ range: VisibleDateRange,
+ mutations: Map,
+): CalendarOccurrence[] {
+ const pattern = (entity.properties as EventObject).pattern!
+ const interval = Math.max(1, Math.trunc(pattern.interval || 1))
+ const maximumOccurrences = pattern.iterations && pattern.iterations > 0
+ ? Math.trunc(pattern.iterations)
+ : null
+ const concludesMs = conclusionTimestamp(pattern.concludes)
+ const weekdays = pattern.onDayOfWeek?.length
+ ? new Set(normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7))
+ : null
+ let step = maximumOccurrences === null || pattern.onDayOfWeek?.length === 0
+ ? dailyStartIndex(start, interval, durationMs, range)
+ : 0
+ let occurrenceCount = weekdays ? 0 : step
+ const occurrences: CalendarOccurrence[] = []
+
+ while (true) {
+ const candidate = shiftLocalDays(start, step * interval)
+ const candidateMs = candidate.getTime()
+ if (candidateMs >= range.endMs) break
+ if (concludesMs !== null && candidateMs > concludesMs) break
+
+ if (!weekdays || weekdays.has(candidate.getDay() || 7)) {
+ occurrenceCount += 1
+ if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break
+
+ const occurrence = materializeOccurrence(
+ entity,
+ candidateMs,
+ durationMs,
+ true,
+ mutations.get(candidateMs),
+ range,
+ )
+ if (occurrence) occurrences.push(occurrence)
+ }
+
+ step += 1
+ }
+
+ return occurrences
+}
+
+function expandWeekly(
+ entity: EntityObject,
+ start: Date,
+ durationMs: number,
+ range: VisibleDateRange,
+ mutations: Map,
+): CalendarOccurrence[] {
+ const pattern = (entity.properties as EventObject).pattern!
+ const interval = Math.max(1, Math.trunc(pattern.interval || 1))
+ const maximumOccurrences = pattern.iterations && pattern.iterations > 0
+ ? Math.trunc(pattern.iterations)
+ : null
+ const concludesMs = conclusionTimestamp(pattern.concludes)
+ const weekdays = normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7)
+ const weekStart = shiftLocalDays(start, -((start.getDay() || 7) - 1))
+ let week = maximumOccurrences === null
+ ? weeklyStartIndex(weekStart, interval, durationMs, range)
+ : 0
+ let occurrenceCount = maximumOccurrences === null ? week * weekdays.length : 0
+ const occurrences: CalendarOccurrence[] = []
+
+ while (true) {
+ const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7)
+ if (intervalWeekStart.getTime() >= range.endMs) break
+
+ for (const weekday of weekdays) {
+ 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
+
+ occurrenceCount += 1
+ if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return occurrences
+
+ const occurrence = materializeOccurrence(
+ entity,
+ candidateMs,
+ durationMs,
+ true,
+ mutations.get(candidateMs),
+ range,
+ )
+ if (occurrence) occurrences.push(occurrence)
+ }
+
+ week += 1
+ }
+
+ return occurrences
+}
+
+function appendMovedMutations(
+ occurrences: CalendarOccurrence[],
+ entity: EntityObject,
+ durationMs: number,
+ range: VisibleDateRange,
+ mutations: Map,
+) {
+ const present = new Set(occurrences.map(occurrence => occurrence.key))
+
+ for (const [sourceStartMs, mutation] of mutations) {
+ const occurrence = materializeOccurrence(
+ entity,
+ sourceStartMs,
+ durationMs,
+ true,
+ mutation,
+ range,
+ )
+ if (occurrence && !present.has(occurrence.key)) {
+ occurrences.push(occurrence)
+ present.add(occurrence.key)
+ }
+ }
+}
+
+export function expandEntityOccurrences(
+ entity: EntityObject,
+ range?: VisibleDateRange,
+): CalendarOccurrence[] {
+ const event = entity.properties as EventObject
+ const startMs = timestamp(event.startsOn)
+ if (startMs === null) return []
+
+ const parsedEndMs = timestamp(event.endsOn)
+ const durationMs = parsedEndMs !== null && parsedEndMs > startMs
+ ? parsedEndMs - startMs
+ : MINIMUM_DURATION_MS
+ 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 mutations = mutationMap(event)
+ const occurrences = pattern.precision === 'daily'
+ ? expandDaily(entity, new Date(startMs), durationMs, range, mutations)
+ : expandWeekly(entity, new Date(startMs), durationMs, range, mutations)
+
+ appendMovedMutations(occurrences, entity, durationMs, range, mutations)
+ occurrences.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
+ return occurrences
+}
diff --git a/tests/js/unit/recurrence.test.ts b/tests/js/unit/recurrence.test.ts
new file mode 100644
index 0000000..64ae3c5
--- /dev/null
+++ b/tests/js/unit/recurrence.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it } from 'vitest'
+import type { EntityObject } from '../../../../chrono_manager/src/models/entity'
+import type { EventOccurrence, EventMutation } from '../../../../chrono_manager/src/types/event'
+import { daysVisibleRange } from '../../../src/utils/dateRanges'
+import { expandEntityOccurrences } from '../../../src/utils/recurrence'
+
+function localIso(year: number, month: number, day: number, hour = 9): string {
+ return new Date(year, month, day, hour).toISOString()
+}
+
+function recurringEntity(
+ pattern: EventOccurrence,
+ mutations: Record> = {},
+ startsOn = localIso(2026, 5, 1),
+): EntityObject {
+ return {
+ identifier: 'calendar:event-1',
+ properties: {
+ startsOn,
+ endsOn: new Date(new Date(startsOn).getTime() + 60 * 60 * 1000).toISOString(),
+ timeless: false,
+ label: 'Standup',
+ color: '#123456',
+ pattern,
+ mutations,
+ },
+ } as unknown as EntityObject
+}
+
+describe('daily recurrence', () => {
+ it('expands interval rules only inside the requested range', () => {
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'daily',
+ interval: 2,
+ })
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 5, 4), 6),
+ )
+
+ expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([5, 7, 9])
+ expect(occurrences.every(occurrence => occurrence.durationMs === 60 * 60 * 1000)).toBe(true)
+ expect(new Set(occurrences.map(occurrence => occurrence.key)).size).toBe(3)
+ })
+
+ it('honors iteration limits before the requested range', () => {
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'daily',
+ interval: 1,
+ iterations: 3,
+ })
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 5, 10), 3),
+ )
+
+ expect(occurrences).toEqual([])
+ })
+
+ it('treats a date-only conclusion as an inclusive local date', () => {
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'daily',
+ interval: 1,
+ concludes: '2026-06-03',
+ })
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 5, 1), 5),
+ )
+
+ expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 2, 3])
+ })
+
+ it('preserves local wall-clock time across daylight-saving changes', () => {
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'daily',
+ interval: 1,
+ }, {}, localIso(2026, 2, 7, 9))
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 2, 7), 3),
+ )
+
+ expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([9, 9, 9])
+ })
+})
+
+describe('weekly recurrence', () => {
+ it('expands selected ISO weekdays in chronological order', () => {
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'weekly',
+ interval: 1,
+ onDayOfWeek: [1, 3],
+ })
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 5, 1), 10),
+ )
+
+ expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 3, 8, 10])
+ })
+})
+
+describe('recurrence mutations', () => {
+ it('removes excluded occurrences', () => {
+ const excludedId = localIso(2026, 5, 2)
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'daily',
+ interval: 1,
+ }, {
+ [excludedId]: {
+ mutationId: excludedId,
+ mutationExclusion: true,
+ },
+ })
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 5, 1), 3),
+ )
+
+ expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 3])
+ })
+
+ it('moves and restyles a modified occurrence without duplicating it', () => {
+ const sourceId = localIso(2026, 5, 2)
+ const movedStart = localIso(2026, 5, 4, 14)
+ const entity = recurringEntity({
+ pattern: 'absolute',
+ precision: 'daily',
+ interval: 1,
+ iterations: 2,
+ }, {
+ [sourceId]: {
+ mutationId: sourceId,
+ mutationExclusion: false,
+ startsOn: movedStart,
+ endsOn: new Date(new Date(movedStart).getTime() + 2 * 60 * 60 * 1000).toISOString(),
+ label: 'Moved standup',
+ color: '#abcdef',
+ },
+ })
+ const occurrences = expandEntityOccurrences(
+ entity,
+ daysVisibleRange(new Date(2026, 5, 1), 5),
+ )
+ const moved = occurrences.find(occurrence => occurrence.occurrenceId === sourceId)
+
+ expect(occurrences).toHaveLength(2)
+ expect(new Date(moved!.startMs).getHours()).toBe(14)
+ expect(moved!.durationMs).toBe(2 * 60 * 60 * 1000)
+ expect(moved!.label).toBe('Moved standup')
+ expect(moved!.color).toBe('#abcdef')
+ })
+})