diff --git a/apps/client/src/widgets/collections/calendar/event_builder.spec.ts b/apps/client/src/widgets/collections/calendar/event_builder.spec.ts index 2c872a14e5..88299e8b65 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.spec.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildNote, buildNotes } from "../../../test/easy-froca.js"; import { buildEvent, buildEvents } from "./event_builder.js"; import { LOCALE_MAPPINGS } from "./index.js"; @@ -148,7 +148,7 @@ describe("Promoted attributes", () => { expect(event).toHaveLength(1); expect(event[0]?.promotedAttributes).toMatchObject([ [ "assignee", "Target note" ] - ]) + ]); }); it("supports start time and end time", async () => { @@ -177,6 +177,86 @@ describe("Promoted attributes", () => { }); + +describe("Recurrence", () => { + it("supports valid recurrence without end date", async () => { + const noteIds = buildNotes([ + { + title: "Recurring Event", + "#startDate": "2025-05-05", + "#recurrence": "FREQ=DAILY;COUNT=5" + } + ]); + const events = await buildEvents(noteIds); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + title: "Recurring Event", + start: "2025-05-05", + }); + expect(events[0].rrule).toContain("DTSTART:20250505"); + expect(events[0].rrule).toContain("FREQ=DAILY;COUNT=5"); + expect(events[0].end).toBeUndefined(); + }); + + it("supports recurrence with start and end time (duration calculated)", async () => { + const noteIds = buildNotes([ + { + title: "Timed Recurring Event", + "#startDate": "2025-05-05", + "#startTime": "13:00", + "#endTime": "15:30", + "#recurrence": "FREQ=WEEKLY;COUNT=3" + } + ]); + const events = await buildEvents(noteIds); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + title: "Timed Recurring Event", + start: "2025-05-05T13:00:00", + duration: "02:30" + }); + expect(events[0].rrule).toContain("DTSTART:20250505T130000"); + expect(events[0].end).toBeUndefined(); + }); + + it("removes end date when recurrence is valid", async () => { + const noteIds = buildNotes([ + { + title: "Recurring With End", + "#startDate": "2025-05-05", + "#endDate": "2025-05-07", + "#recurrence": "FREQ=DAILY;COUNT=2" + } + ]); + const events = await buildEvents(noteIds); + + expect(events).toHaveLength(1); + expect(events[0].rrule).toBeDefined(); + expect(events[0].end).toBeUndefined(); + }); + + it("writes to console on invalid recurrence rule", async () => { + const noteIds = buildNotes([ + { + title: "Invalid Recurrence", + "#startDate": "2025-05-05", + "#recurrence": "RRULE:FREQ=INVALID" + } + ]); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + await buildEvents(noteIds); + const calledWithInvalid = consoleSpy.mock.calls.some(call => + call[0].includes("has an invalid #recurrence string") + ); + expect(calledWithInvalid).toBe(true); + consoleSpy.mockRestore(); + }); +}); + + describe("Building locales", () => { it("every language has a locale defined", async () => { for (const { id, contentOnly } of LOCALES) { diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index cd27a89a5b..92952ca68b 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -1,12 +1,14 @@ import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js"; +import { dayjs } from "@triliumnext/commons"; import clsx from "clsx"; +import { start } from "repl"; import * as rruleLib from 'rrule'; import FNote from "../../../entities/fnote"; import froca from "../../../services/froca"; import server from "../../../services/server"; import toastService from "../../../services/toast"; -import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils"; +import { getCustomisableLabel, getMonthsInDateRange } from "./utils"; interface Event { startDate: string, @@ -127,9 +129,10 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e startDate = (startTime ? `${startDate}T${startTime}:00` : startDate); if (!startTime) { - const endDateOffset = offsetDate(endDate ?? startDate, 1); - if (endDateOffset) { - endDate = formatDateToLocalISO(endDateOffset); + if (endDate) { + endDate = dayjs(endDate).add(1, "day").format("YYYY-MM-DD"); + } else if (startDate) { + endDate = dayjs(startDate).add(1, "day").format("YYYY-MM-DD"); } } @@ -150,7 +153,7 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e if (recurrence) { // Generate rrule string - const rruleString = `DTSTART:${startDate.replace(/[-:]/g, "")}\n${recurrence}`; + const rruleString = `DTSTART:${dayjs(startDate).format("YYYYMMDD[T]HHmmss")}\n${recurrence}`; // Validate rrule string let rruleValid = true; @@ -164,13 +167,11 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e delete eventData.end; eventData.rrule = rruleString; if (endDate){ - const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime(); - const hours = Math.floor(diffMs / 3600000); - const minutes = Math.floor((diffMs / 60000) % 60); - eventData.duration = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; + const duration = dayjs.duration(dayjs(endDate).diff(dayjs(startDate))); + eventData.duration = duration.format("HH:mm"); } } else { - throw new Error(`Note "${note.noteId} ${note.title}" has an invalid #recurrence string. Excluding...`); + throw new Error(`Note "${note.noteId} ${note.title}" has an invalid #recurrence string ${recurrence}. Excluding...`); } } events.push(eventData);