Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions server/src/__tests__/frontend-calendar-recurrence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Test frontend recurrence math against the exact month-end, leap year, and
* bi-weekly test cases to ensure client calendar views stay in parity with the
* backend dispatcher.
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { nextOccurrenceOnOrAfter } from '../../../src/lib/recurrence.js'
import type { RecurrenceRule } from '../../../src/types.js'

const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

function series(seedIso: string, rule: RecurrenceRule | null, n: number): string[] {
const seed = new Date(seedIso)
const out: string[] = []
let after = seed
for (let i = 0; i < n; i++) {
const next = nextOccurrenceOnOrAfter(seed, rule, after)
if (!next) break
out.push(next.toISOString().slice(0, 10))
after = new Date(next.getTime() + 1000)
}
return out
}

function weekdays(seedIso: string, rule: RecurrenceRule, n: number): string[] {
return series(seedIso, rule, n).map((d) => `${d} ${DOW[new Date(`${d}T00:00:00Z`).getUTCDay()]}`)
}

test('frontend recurrence: monthly on the 31st clamps into short months and comes back', () => {
assert.deepEqual(
series('2026-01-31T09:00:00Z', { freq: 'monthly', interval: 1 }, 7),
['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30', '2026-05-31', '2026-06-30', '2026-07-31'],
)
})

test('frontend recurrence: yearly on Feb 29 returns at the next leap year', () => {
assert.deepEqual(
series('2028-02-29T09:00:00Z', { freq: 'yearly', interval: 1 }, 6),
['2028-02-29', '2029-02-28', '2030-02-28', '2031-02-28', '2032-02-29', '2033-02-28'],
)
})

test('frontend recurrence: every-2-weeks on Mon and Wed keeps both days in the same week', () => {
assert.deepEqual(
weekdays('2026-01-05T09:00:00Z', { freq: 'weekly', interval: 2, byweekday: [1, 3] }, 6),
[
'2026-01-05 Mon', '2026-01-07 Wed',
'2026-01-19 Mon', '2026-01-21 Wed',
'2026-02-02 Mon', '2026-02-04 Wed',
],
)
})

test('frontend recurrence: non-recurring event fires once then terminates', () => {
const seed = new Date('2026-04-14T09:00:00Z')
assert.deepEqual(nextOccurrenceOnOrAfter(seed, null, seed), seed)
assert.equal(nextOccurrenceOnOrAfter(seed, null, new Date(seed.getTime() + 1000)), null)
})

41 changes: 2 additions & 39 deletions src/desktop/CalendarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { IPlus, ICalendar, IClock, IRepeat, ITrash } from '@/components/icons'
import { EventEditor, type EventEditorPrefill } from '@/components/EventEditor'
import { useT } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { nextOccurrenceOnOrAfter } from '@/lib/recurrence'
import type { CalendarEvent, RecurrenceRule } from '@/types'

interface AgendaItem {
Expand Down Expand Up @@ -77,47 +78,9 @@ function addDays(d: Date, n: number): Date { const out = new Date(d); out.setDat

/* ─────────────────────────── recurrence (client mirror) ─────────────────────────── */

function stepRule(from: Date, rule: RecurrenceRule): Date {
const interval = Math.max(1, Math.floor(rule.interval || 1))
switch (rule.freq) {
case 'daily':
return new Date(from.getTime() + interval * DAY_MS)
case 'weekly': {
const days = rule.byweekday && rule.byweekday.length ? [...rule.byweekday].sort((a, b) => a - b) : null
if (!days) return new Date(from.getTime() + interval * 7 * DAY_MS)
let cand = new Date(from.getTime() + ((interval - 1) * 7 + 1) * DAY_MS)
for (let i = 0; i < 14; i++) {
if (days.includes(cand.getDay())) return cand
cand = new Date(cand.getTime() + DAY_MS)
}
return cand
}
case 'monthly': {
const out = new Date(from); out.setMonth(out.getMonth() + interval); return out
}
case 'yearly': {
const out = new Date(from); out.setFullYear(out.getFullYear() + interval); return out
}
}
}

function nextOccurrence(event: CalendarEvent, from: Date): Date | null {
const startAt = new Date(event.startAt)
if (event.status !== 'active') return null
if (!event.recurrence) return startAt.getTime() >= from.getTime() ? startAt : null
const rule = event.recurrence
const untilTs = rule.until ? new Date(rule.until).getTime() : Infinity
const maxCount = rule.count ?? Infinity
let current = new Date(startAt)
let fired = 1
for (let i = 0; i < 5000; i++) {
if (current.getTime() > untilTs) return null
if (fired > maxCount) return null
if (current.getTime() >= from.getTime()) return current
current = stepRule(current, rule)
fired += 1
}
return null
return nextOccurrenceOnOrAfter(new Date(event.startAt), event.recurrence ?? null, from)
}

function describeRecurrence(r: RecurrenceRule | null, t: ReturnType<typeof useT>): string {
Expand Down
109 changes: 109 additions & 0 deletions src/lib/recurrence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Pure recurrence math for frontend calendar views.
*
* Mirrors the indexed recurrence calculations from server/src/recurrence.ts
* so desktop and mobile calendar views display the exact same dates where the
* backend dispatcher fires them.
*
* Every occurrence is computed FROM THE SEED by index rather than stepping
* from the previous occurrence. Stepping caused unrepresentable dates
* (e.g. Jan 31 + 1 month overflowing to March 3 in setUTCMonth) to permanently
* displace all future occurrences.
*/
import type { RecurrenceRule } from '../types.js'

export type { RecurrenceRule }

/** Add N days to a Date without mutating the input. */
function addDays(d: Date, n: number): Date {
const out = new Date(d.getTime())
out.setUTCDate(out.getUTCDate() + n)
return out
}

/**
* `seed` shifted by N months, with the day-of-month CLAMPED into the target
* month rather than overflowing into the next one.
*
* `setUTCMonth` overflows: a Jan 31 date asked for February becomes March 3.
* Anchoring on the seed and clamping keeps Jan 31 -> Feb 28 -> Mar 31.
*/
function addMonthsClamped(seed: Date, n: number): Date {
const day = seed.getUTCDate()
const target = new Date(Date.UTC(
seed.getUTCFullYear(), seed.getUTCMonth() + n, 1,
seed.getUTCHours(), seed.getUTCMinutes(), seed.getUTCSeconds(), seed.getUTCMilliseconds(),
))
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate()
target.setUTCDate(Math.min(day, lastDay))
return target
}

/**
* The weekdays a weekly rule fires on, or null when it should just use the
* seed's own weekday. Sorted and de-duplicated so the ordering below is
* stable regardless of how the caller wrote the rule.
*/
function weeklyDays(rule: RecurrenceRule): number[] | null {
const raw = rule.byweekday
if (!raw || raw.length === 0) return null
const days = [...new Set(raw.filter((d): d is number => Number.isInteger(d) && d >= 0 && d <= 6))].sort((a, b) => a - b)
return days.length > 0 ? days : null
}

/**
* The `index`-th occurrence of a rule, counted from the seed (index 0 IS the
* seed). Every case is computed FROM THE SEED rather than from the previous
* occurrence, keeping unrepresentable dates from permanently displacing the series.
*/
function occurrenceAt(seed: Date, rule: RecurrenceRule, index: number): Date {
const interval = Math.max(1, Math.floor(rule.interval || 1))
switch (rule.freq) {
case 'daily':
return addDays(seed, index * interval)
case 'weekly': {
const days = weeklyDays(rule)
if (!days) return addDays(seed, index * interval * 7)
const seedDow = seed.getUTCDay()
const firstWeek = days.filter((d) => d >= seedDow)
if (index < firstWeek.length) return addDays(seed, firstWeek[index] - seedDow)
const rest = index - firstWeek.length
const week = Math.floor(rest / days.length) + 1
const dow = days[rest % days.length]
const weekStart = addDays(seed, -seedDow)
return addDays(weekStart, week * interval * 7 + dow)
}
case 'monthly':
return addMonthsClamped(seed, index * interval)
case 'yearly':
return addMonthsClamped(seed, index * interval * 12)
default:
return seed
}
}

/**
* Compute the next firing time strictly >= `after`, walking forward from
* the event's seed `startAt`. Returns null if:
* - the series has no recurrence and startAt < after (already fired)
* - rule.until is earlier than the next computed slot
* - rule.count is exhausted
*/
export function nextOccurrenceOnOrAfter(
startAt: Date,
recurrence: RecurrenceRule | null,
after: Date,
): Date | null {
if (!recurrence) {
return startAt.getTime() >= after.getTime() ? startAt : null
}
const untilTs = recurrence.until ? new Date(recurrence.until).getTime() : Infinity
const maxCount = recurrence.count ?? Infinity
for (let index = 0; index < 5000; index++) {
if (index + 1 > maxCount) return null
const current = occurrenceAt(startAt, recurrence, index)
if (current.getTime() > untilTs) return null
if (current.getTime() >= after.getTime()) return current
}
return null
}
43 changes: 3 additions & 40 deletions src/mobile/MobileCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { ICalendar, IClock, IRepeat } from '@/components/icons'
import { tapHaptic } from '@/lib/native'
import { useT } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import type { CalendarEvent, RecurrenceRule } from '@/types'
import { nextOccurrenceOnOrAfter } from '@/lib/recurrence'
import type { CalendarEvent } from '@/types'

const DAY_MS = 86_400_000
const WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
Expand All @@ -42,47 +43,9 @@ function formatTime(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}

function stepRule(from: Date, rule: RecurrenceRule): Date {
const interval = Math.max(1, Math.floor(rule.interval || 1))
switch (rule.freq) {
case 'daily':
return new Date(from.getTime() + interval * DAY_MS)
case 'weekly': {
const days = rule.byweekday && rule.byweekday.length ? [...rule.byweekday].sort((a, b) => a - b) : null
if (!days) return new Date(from.getTime() + interval * 7 * DAY_MS)
let cand = new Date(from.getTime() + ((interval - 1) * 7 + 1) * DAY_MS)
for (let i = 0; i < 14; i++) {
if (days.includes(cand.getDay())) return cand
cand = new Date(cand.getTime() + DAY_MS)
}
return cand
}
case 'monthly': {
const out = new Date(from); out.setMonth(out.getMonth() + interval); return out
}
case 'yearly': {
const out = new Date(from); out.setFullYear(out.getFullYear() + interval); return out
}
}
}

function nextOccurrence(event: CalendarEvent, from: Date): Date | null {
const startAt = new Date(event.startAt)
if (event.status !== 'active') return null
if (!event.recurrence) return startAt.getTime() >= from.getTime() ? startAt : null
const rule = event.recurrence
const untilTs = rule.until ? new Date(rule.until).getTime() : Infinity
const maxCount = rule.count ?? Infinity
let current = new Date(startAt)
let fired = 1
for (let i = 0; i < 5000; i++) {
if (current.getTime() > untilTs) return null
if (fired > maxCount) return null
if (current.getTime() >= from.getTime()) return current
current = stepRule(current, rule)
fired += 1
}
return null
return nextOccurrenceOnOrAfter(new Date(event.startAt), event.recurrence ?? null, from)
}

function expandToRange(events: CalendarEvent[], start: Date, end: Date): AgendaItem[] {
Expand Down
Loading