diff --git a/.changeset/event-helper-rrule.md b/.changeset/event-helper-rrule.md new file mode 100644 index 0000000..2ddda8c --- /dev/null +++ b/.changeset/event-helper-rrule.md @@ -0,0 +1,6 @@ +--- +"@churchapps/helpers": patch +"@churchapps/apphelper": patch +--- + +Rebuild weekly RRules from the event start date and match exception dates by calendar day. diff --git a/apphelper/src/website/components/eventCalendar/RRuleEditor.tsx b/apphelper/src/website/components/eventCalendar/RRuleEditor.tsx index 74b2cbe..7b7a2a4 100644 --- a/apphelper/src/website/components/eventCalendar/RRuleEditor.tsx +++ b/apphelper/src/website/components/eventCalendar/RRuleEditor.tsx @@ -166,6 +166,25 @@ export function RRuleEditor(props: Props) { props.onChange(result); }, [rRuleOptions, props.onChange]); + const startDateKey = `${props.start.getFullYear()}-${props.start.getMonth()}-${props.start.getDate()}`; + const prevStartDateKey = React.useRef(startDateKey); + useEffect(() => { + if (prevStartDateKey.current === startDateKey) return; + prevStartDateKey.current = startDateKey; + setRRuleOptions((prev) => { + const options = { ...prev, dtstart: props.start }; + // Only weekly rules are pinned to the start date's weekday; daily rules must stay every-day. + if (options.freq === RRule.WEEKLY) { + let startDay = props.start.getDay() - 1; + if (startDay === -1) startDay = 6; + options.byweekday = [startDay]; + } else if (options.freq === RRule.MONTHLY && options.bymonthday?.length > 0) { + options.bymonthday = [props.start.getDate() || 1]; + } + return options; + }); + }, [startDateKey, props.start]); + return ( <> diff --git a/helpers/src/EventHelper.ts b/helpers/src/EventHelper.ts index 2b67f51..4d7d8e3 100644 --- a/helpers/src/EventHelper.ts +++ b/helpers/src/EventHelper.ts @@ -50,17 +50,24 @@ export class EventHelper { static getFullRRule = (event:EventInterface) => { const RR = getRRule(); - const rrule = RR.fromString(event.recurrenceRule); - rrule.options.dtstart = new Date(event.start!); - return rrule; + // Reconstruct so implicit BYDAY/BYMONTHDAY derive from event.start, not parse-time "now". + const options = { ...RR.parseString(event.recurrenceRule || "") }; + options.dtstart = new Date(event.start!); + return new RR(options); + }; + + static calendarDateKey = (d: string | Date) => { + const dt = new Date(d); + if (isNaN(dt.getTime())) return ""; + return `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`; }; static removeExcludeDates = (events:EventInterface[]) => { for (let i = events.length - 1; i >= 0; i--) { const exceptionDates = events[i].exceptionDates; if (exceptionDates && exceptionDates.length > 0) { - const parsedDates = exceptionDates.map((d: string | Date)=>new Date(d).toISOString()); - if (parsedDates.indexOf(events[i].start!.toISOString()) > -1) events.splice(i, 1); + const keys = exceptionDates.map((d: string | Date) => EventHelper.calendarDateKey(d)); + if (keys.indexOf(EventHelper.calendarDateKey(events[i].start!)) > -1) events.splice(i, 1); } } }; diff --git a/helpers/tests/eventHelper.test.ts b/helpers/tests/eventHelper.test.ts new file mode 100644 index 0000000..66b3d3d --- /dev/null +++ b/helpers/tests/eventHelper.test.ts @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { EventHelper } from "../src/EventHelper"; +import type { EventInterface } from "../src/interfaces"; + +await EventHelper.ensureInitialized(); + +const localKey = (d: Date) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; + +test("getFullRRule includes the selected start date for weekly rules without BYDAY", () => { + // Thursday Jan 8, 2026 — the reporter's "1st date selected" + const start = new Date(2026, 0, 8, 18, 30, 0); + const event: EventInterface = { + start, + end: new Date(2026, 0, 8, 20, 0, 0), + recurrenceRule: "FREQ=WEEKLY;INTERVAL=1;UNTIL=20260301T235959Z" + }; + const range = EventHelper.getRange(event, new Date(2026, 0, 1), new Date(2026, 0, 31, 23, 59, 59)); + const dates = range.map(localKey); + assert.ok(dates.includes("2026-1-8"), `first occurrence missing, got ${dates.join(",")}`); + for (const d of range) assert.equal(d.getDay(), 4, "weekly series should stay on Thursday"); +}); + +test("getFullRRule still honors an explicit BYDAY", () => { + const start = new Date(2026, 6, 1, 18, 30, 0); // Wednesday + const event: EventInterface = { + start, + end: new Date(2026, 6, 1, 20, 0, 0), + recurrenceRule: "FREQ=WEEKLY;BYDAY=WE" + }; + const range = EventHelper.getRange(event, new Date(2026, 6, 1), new Date(2026, 6, 31, 23, 59, 59)); + assert.ok(range.length >= 4); + for (const d of range) assert.equal(d.getDay(), 3); +}); + +test("removeExcludeDates drops an occurrence matched by calendar date, not exact ISO timestamp", () => { + const start = new Date(2026, 0, 8, 18, 30, 0); + const events: EventInterface[] = [ + { id: "keep", start: new Date(2026, 0, 15, 18, 30, 0), exceptionDates: [new Date(2026, 0, 8, 12, 0, 0)] as any }, + { id: "drop", start, exceptionDates: [new Date(2026, 0, 8, 12, 0, 0)] as any } + ]; + EventHelper.removeExcludeDates(events); + assert.equal(events.length, 1); + assert.equal(events[0].id, "keep"); +}); + +test("removeExcludeDates matches mysql-style datetime strings that would fail ISO equality", () => { + const start = new Date(2026, 0, 8, 18, 30, 0); + const events: EventInterface[] = [ + { id: "drop", start, exceptionDates: ["2026-01-08 12:00:00"] as any } + ]; + EventHelper.removeExcludeDates(events); + assert.equal(events.length, 0); +});