···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788+## [0.27.0] — 2026-04-09
99+1010+### Added
1111+- Add recurring events to calendar (daily, weekly, monthly, yearly with optional end date) (#572)
1212+- Add RRULE support to ICS export for recurring events (#572)
1313+814## [0.26.0] — 2026-04-09
9151016### Added
+111
src/calendar/helpers.ts
···22 * Calendar pure helper functions — extracted for testability.
33 */
4455+export type RecurrenceType = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
66+77+export interface Recurrence {
88+ type: RecurrenceType;
99+ /** End date for recurrence (YYYY-MM-DD), or empty for no end */
1010+ until?: string;
1111+}
1212+513export interface CalendarEvent {
614 id: string;
715 title: string;
···1220 allDay: boolean;
1321 color: string;
1422 description: string;
2323+ recurrence?: Recurrence;
1524 createdAt: number;
1625 updatedAt: number;
1726}
···132141 const totalDays = daysInMonth(year, month);
133142 return Math.ceil((firstDay + totalDays) / 7) * 7;
134143}
144144+145145+/**
146146+ * Expand recurring events into concrete instances within a date range.
147147+ * Non-recurring events are passed through unchanged.
148148+ * Recurring events produce virtual copies with adjusted dates.
149149+ */
150150+export function expandRecurringEvents(
151151+ events: CalendarEvent[],
152152+ rangeStart: string,
153153+ rangeEnd: string,
154154+): CalendarEvent[] {
155155+ const result: CalendarEvent[] = [];
156156+157157+ for (const evt of events) {
158158+ if (!evt.recurrence || evt.recurrence.type === 'none') {
159159+ result.push(evt);
160160+ continue;
161161+ }
162162+163163+ const { type, until } = evt.recurrence;
164164+ const effectiveEnd = until && until < rangeEnd ? until : rangeEnd;
165165+ const eventDuration = evt.endDate
166166+ ? daysBetween(evt.date, evt.endDate)
167167+ : 0;
168168+169169+ let current = parseEventDate(evt.date);
170170+ const endDate = parseEventDate(effectiveEnd);
171171+ const startRange = parseEventDate(rangeStart);
172172+173173+ // Cap at 365 iterations to prevent runaway loops
174174+ let iterations = 0;
175175+ while (current <= endDate && iterations < 365) {
176176+ const dateStr = formatDate(current);
177177+178178+ // Only include if the instance falls within the visible range
179179+ const instanceEnd = eventDuration > 0
180180+ ? formatDate(addDays(current, eventDuration))
181181+ : dateStr;
182182+183183+ if (instanceEnd >= rangeStart && dateStr <= rangeEnd) {
184184+ const instance: CalendarEvent = {
185185+ ...evt,
186186+ date: dateStr,
187187+ ...(eventDuration > 0 ? { endDate: instanceEnd } : {}),
188188+ };
189189+ result.push(instance);
190190+ }
191191+192192+ // Advance to next occurrence
193193+ current = nextOccurrence(current, type);
194194+ iterations++;
195195+196196+ // Skip instances before our visible range
197197+ if (current < startRange && iterations < 365) {
198198+ continue;
199199+ }
200200+ }
201201+ }
202202+203203+ return result;
204204+}
205205+206206+function daysBetween(startStr: string, endStr: string): number {
207207+ const a = parseEventDate(startStr).getTime();
208208+ const b = parseEventDate(endStr).getTime();
209209+ return Math.round((b - a) / (24 * 60 * 60 * 1000));
210210+}
211211+212212+function addDays(d: Date, n: number): Date {
213213+ const result = new Date(d);
214214+ result.setDate(result.getDate() + n);
215215+ return result;
216216+}
217217+218218+function nextOccurrence(current: Date, type: RecurrenceType): Date {
219219+ const next = new Date(current);
220220+ switch (type) {
221221+ case 'daily':
222222+ next.setDate(next.getDate() + 1);
223223+ break;
224224+ case 'weekly':
225225+ next.setDate(next.getDate() + 7);
226226+ break;
227227+ case 'monthly':
228228+ next.setMonth(next.getMonth() + 1);
229229+ break;
230230+ case 'yearly':
231231+ next.setFullYear(next.getFullYear() + 1);
232232+ break;
233233+ default:
234234+ next.setDate(next.getDate() + 1);
235235+ }
236236+ return next;
237237+}
238238+239239+export const RECURRENCE_OPTIONS: Array<{ type: RecurrenceType; label: string }> = [
240240+ { type: 'none', label: 'Does not repeat' },
241241+ { type: 'daily', label: 'Daily' },
242242+ { type: 'weekly', label: 'Weekly' },
243243+ { type: 'monthly', label: 'Monthly' },
244244+ { type: 'yearly', label: 'Yearly' },
245245+];