diff --git a/bun.lock b/bun.lock index cfab850..86a3f2f 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "react": "19.1.0", "react-day-picker": "^9.9.0", "react-dom": "19.1.0", + "rrule": "^2.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "zod": "^4.3.6", @@ -1016,6 +1017,7 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1038,6 +1040,8 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "rrule": ["rrule@2.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], diff --git a/package.json b/package.json index ed030ae..54d1a79 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react": "19.1.0", "react-day-picker": "^9.9.0", "react-dom": "19.1.0", + "rrule": "^2.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "zod": "^4.3.6" diff --git a/src/components/recurrence-picker.tsx b/src/components/recurrence-picker.tsx index eaa2388..d89e14b 100644 --- a/src/components/recurrence-picker.tsx +++ b/src/components/recurrence-picker.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { format, parseISO } from "date-fns"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -11,75 +11,63 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; - -type Recurrence = { - freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"; - interval: number; - byDay?: string[]; - count?: number; - until?: string; -}; +import { + getRecurrencePreview, + getWeekdayOptions, + parseRecurrenceRule, + recurrenceFrequencyLabels, + serializeRecurrenceRule, + validateRecurrence, + type RecurrenceFormValue, + type SupportedRecurrenceFrequency, + type Weekday, +} from "@/lib/recurrence"; interface Props { value?: string; + start?: string; onChange: (rrule: string | undefined) => void; } -export function RecurrencePicker({ value, onChange }: Props) { - const [rec, setRec] = useState(() => { - // If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL) - if (value) { - const parts = Object.fromEntries( - value.split(";").map((p) => p.split("=")), - ); - return { - freq: parts.FREQ || "NONE", - interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1, - byDay: parts.BYDAY ? parts.BYDAY.split(",") : [], - count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined, - until: parts.UNTIL, - }; - } - return { freq: "NONE", interval: 1 }; - }); +const weekdayOptions = getWeekdayOptions(); - const update = (updates: Partial) => { - const newRec = { ...rec, ...updates }; - setRec(newRec); +const getStartWeekday = (start?: string): Weekday => { + if (!start) return "MO"; + const parsed = parseISO(start); + const jsDay = parsed.getDay(); + const weekdays: Weekday[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + return weekdays[jsDay] ?? "MO"; +}; - if (newRec.freq === "NONE") { +const updateWeekdays = ( + current: Weekday[], + day: Weekday, +): Weekday[] => { + return current.includes(day) + ? current.filter((existingDay) => existingDay !== day) + : [...current, day]; +}; + +export function RecurrencePicker({ value, start, onChange }: Props) { + const recurrence = parseRecurrenceRule(value); + const validation = validateRecurrence(recurrence); + const preview = start ? getRecurrencePreview(recurrence, start, 3) : []; + + const update = (updates: Partial) => { + const nextValue = { ...recurrence, ...updates }; + + if (nextValue.freq === "NONE") { onChange(undefined); return; } - // Build RRULE string - let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`; - if (newRec.freq === "WEEKLY" && newRec.byDay?.length) { - rrule += `;BYDAY=${newRec.byDay.join(",")}`; + const nextValidation = validateRecurrence(nextValue); + if (!nextValidation.isValid && nextValidation.errors.rule) { + onChange(value); + return; } - if (newRec.count) rrule += `;COUNT=${newRec.count}`; - if (newRec.until) - rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`; - onChange(rrule); - }; - - const toggleDay = (day: string) => { - const byDay = rec.byDay || []; - const newByDay = byDay.includes(day) - ? byDay.filter((d) => d !== day) - : [...byDay, day]; - update({ byDay: newByDay }); - }; - - const dayLabels = { - MO: "Mon", - TU: "Tue", - WE: "Wed", - TH: "Thu", - FR: "Fri", - SA: "Sat", - SU: "Sun", + onChange(serializeRecurrenceRule(nextValue, start)); }; return ( @@ -89,65 +77,67 @@ export function RecurrencePicker({ value, onChange }: Props) { Repeats - {rec.freq !== "NONE" && ( + {recurrence.freq !== "NONE" && ( <>
- update({ interval: Number.parseInt(e.target.value, 10) || 1 }) + value={recurrence.interval} + onChange={(event) => + update({ interval: Number.parseInt(event.target.value, 10) || 1 }) } className="mt-1.5 w-24" />
- {rec.freq === "WEEKLY" && ( + {recurrence.freq === "WEEKLY" && (
- -
- {["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => ( + +
+ {weekdayOptions.map(({ value: day, label }) => (
toggleDay(day)} + checked={recurrence.byDay.includes(day)} + onCheckedChange={() => + update({ byDay: updateWeekdays(recurrence.byDay, day) }) + } /> -
))} @@ -164,12 +154,13 @@ export function RecurrencePicker({ value, onChange }: Props) { id="count" type="number" placeholder="e.g. 10" - value={rec.count || ""} - onChange={(e) => + value={recurrence.count || ""} + onChange={(event) => update({ - count: e.target.value - ? Number.parseInt(e.target.value, 10) + count: event.target.value + ? Number.parseInt(event.target.value, 10) : undefined, + until: event.target.value ? undefined : recurrence.until, }) } className="mt-1.5" @@ -182,12 +173,38 @@ export function RecurrencePicker({ value, onChange }: Props) { update({ until: e.target.value || undefined })} + value={recurrence.until || ""} + onChange={(event) => + update({ + until: event.target.value || undefined, + count: event.target.value ? undefined : recurrence.count, + }) + } className="mt-1.5" />
+ + {validation.errors.count && ( +

{validation.errors.count}

+ )} + {validation.errors.until && ( +

{validation.errors.until}

+ )} + {validation.errors.rule && ( +

{validation.errors.rule}

+ )} + + {preview.length > 0 && ( +
+

Upcoming

+

+ {preview + .map((entry) => format(parseISO(entry), "EEE, MMM d")) + .join(" ยท ")} +

+
+ )} )}
diff --git a/src/components/rrule-display.tsx b/src/components/rrule-display.tsx index fb36a4f..64113cc 100644 --- a/src/components/rrule-display.tsx +++ b/src/components/rrule-display.tsx @@ -1,308 +1,31 @@ +import { format, parseISO } from "date-fns"; import { Badge } from "@/components/ui/badge"; -import type { RecurrenceRule } from "@/lib/rfc5545-types"; +import { formatRecurrenceText, getRecurrencePreview, parseRecurrenceRule } from "@/lib/recurrence"; interface RRuleDisplayProps { - rrule: string | RecurrenceRule; + rrule?: string; className?: string; + start?: string; } -export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) { - const parsedRule = - typeof rrule === "string" ? parseRRuleString(rrule) : rrule; - const humanText = formatRRuleToHuman(parsedRule); +export function RRuleDisplay({ rrule, className, start }: RRuleDisplayProps) { + if (!rrule) return null; + + const humanText = formatRecurrenceText(rrule); + const preview = start ? getRecurrencePreview(parseRecurrenceRule(rrule), start, 3) : []; return (
- - {humanText} - -
- ); -} - -interface RRuleDisplayDetailedProps { - rrule: string | RecurrenceRule; - className?: string; - showBadges?: boolean; -} - -export function RRuleDisplayDetailed({ - rrule, - className, - showBadges = true, -}: RRuleDisplayDetailedProps) { - const parsedRule = - typeof rrule === "string" ? parseRRuleString(rrule) : rrule; - const humanText = formatRRuleToHuman(parsedRule); - const details = getRRuleDetails(parsedRule); - - return ( -
-
-
{humanText}
- - {showBadges && details.length > 0 && ( -
- {details.map((detail) => ( - - {detail} - - ))} -
+
+ + {humanText ?? rrule} + + {preview.length > 0 && ( + + Next: {preview.map((value) => format(parseISO(value), "MMM d")).join(", ")} + )}
); } - -function parseRRuleString(rruleString: string): RecurrenceRule { - const parts = Object.fromEntries( - rruleString.split(";").map((p) => p.split("=")), - ); - - return { - freq: parts.FREQ as RecurrenceRule["freq"], - until: parts.UNTIL - ? new Date( - parts.UNTIL.replace( - /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/, - "$1-$2-$3T$4:$5:$6Z", - ), - ).toISOString() - : undefined, - count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined, - interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined, - bySecond: parts.BYSECOND - ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - byMinute: parts.BYMINUTE - ? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - byHour: parts.BYHOUR - ? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined, - byMonthDay: parts.BYMONTHDAY - ? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - byYearDay: parts.BYYEARDAY - ? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - byWeekNo: parts.BYWEEKNO - ? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - byMonth: parts.BYMONTH - ? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - bySetPos: parts.BYSETPOS - ? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10)) - : undefined, - wkst: parts.WKST as RecurrenceRule["wkst"], - }; -} - -function formatRRuleToHuman(rule: RecurrenceRule): string { - const { - freq, - interval = 1, - count, - until, - byDay, - byMonthDay, - byMonth, - byHour, - byMinute, - bySecond, - } = rule; - - let text = ""; - - // Base frequency - switch (freq) { - case "SECONDLY": - text = interval === 1 ? "Every second" : `Every ${interval} seconds`; - break; - case "MINUTELY": - text = interval === 1 ? "Every minute" : `Every ${interval} minutes`; - break; - case "HOURLY": - text = interval === 1 ? "Every hour" : `Every ${interval} hours`; - break; - case "DAILY": - text = interval === 1 ? "Daily" : `Every ${interval} days`; - break; - case "WEEKLY": - text = interval === 1 ? "Weekly" : `Every ${interval} weeks`; - break; - case "MONTHLY": - text = interval === 1 ? "Monthly" : `Every ${interval} months`; - break; - case "YEARLY": - text = interval === 1 ? "Yearly" : `Every ${interval} years`; - break; - } - - // Add day specifications - if (byDay?.length) { - const dayNames = { - SU: "Sunday", - MO: "Monday", - TU: "Tuesday", - WE: "Wednesday", - TH: "Thursday", - FR: "Friday", - SA: "Saturday", - }; - - const days = byDay.map((day) => { - // Handle numbered days like "2TU" (second Tuesday) - const match = day.match(/^(-?\d+)?([A-Z]{2})$/); - if (match) { - const [, num, dayCode] = match; - const dayName = dayNames[dayCode as keyof typeof dayNames]; - if (num) { - const ordinal = getOrdinal(parseInt(num, 10)); - return `${ordinal} ${dayName}`; - } - return dayName; - } - return day; - }); - - if (freq === "WEEKLY") { - text += ` on ${formatList(days)}`; - } else { - text += ` on ${formatList(days)}`; - } - } - - // Add month day specifications - if (byMonthDay?.length) { - const days = byMonthDay.map((day) => { - if (day < 0) { - return `${getOrdinal(Math.abs(day))} to last day`; - } - return getOrdinal(day); - }); - text += ` on the ${formatList(days)}`; - } - - // Add month specifications - if (byMonth?.length) { - const monthNames = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; - const months = byMonth.map((month) => monthNames[month - 1]); - text += ` in ${formatList(months)}`; - } - - // Add time specifications - if (byHour?.length || byMinute?.length || bySecond?.length) { - const timeSpecs = []; - if (byHour?.length) { - const hours = byHour.map((h) => `${h.toString().padStart(2, "0")}:00`); - timeSpecs.push(`at ${formatList(hours)}`); - } - if (byMinute?.length && !byHour?.length) { - timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`); - } - if (bySecond?.length && !byHour?.length && !byMinute?.length) { - timeSpecs.push(`at second ${formatList(bySecond.map(String))}`); - } - if (timeSpecs.length) { - text += ` ${timeSpecs.join(" ")}`; - } - } - - // Add end conditions - if (count) { - text += `, ${count} time${count === 1 ? "" : "s"}`; - } else if (until) { - const date = new Date(until); - text += `, until ${date.toLocaleDateString()}`; - } - - return text; -} - -function getRRuleDetails(rule: RecurrenceRule): string[] { - const details: string[] = []; - - if (rule.wkst && rule.wkst !== "MO") { - const dayNames = { - SU: "Sunday", - MO: "Monday", - TU: "Tuesday", - WE: "Wednesday", - TH: "Thursday", - FR: "Friday", - SA: "Saturday", - }; - details.push(`Week starts ${dayNames[rule.wkst]}`); - } - - if (rule.byWeekNo?.length) { - details.push(`Week ${formatList(rule.byWeekNo.map(String))}`); - } - - if (rule.byYearDay?.length) { - details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`); - } - - if (rule.bySetPos?.length) { - const positions = rule.bySetPos.map((pos) => { - if (pos < 0) { - return `${getOrdinal(Math.abs(pos))} to last`; - } - return getOrdinal(pos); - }); - details.push(`Position ${formatList(positions)}`); - } - - return details; -} - -function getOrdinal(num: number): string { - const suffix = ["th", "st", "nd", "rd"]; - const v = num % 100; - return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]); -} - -function formatList(items: string[]): string { - if (items.length === 0) return ""; - if (items.length === 1) return items[0]; - if (items.length === 2) return `${items[0]} and ${items[1]}`; - return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; -} - -// Hook for easy usage in components -export function useRRuleDisplay(rrule?: string) { - if (!rrule) return null; - - try { - const parsedRule = parseRRuleString(rrule); - return { - humanText: formatRRuleToHuman(parsedRule), - details: getRRuleDetails(parsedRule), - parsedRule, - }; - } catch (error) { - return { - humanText: "Invalid recurrence rule", - details: [], - parsedRule: null, - error: error instanceof Error ? error.message : String(error), - }; - } -} diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts new file mode 100644 index 0000000..0251315 --- /dev/null +++ b/src/lib/recurrence.ts @@ -0,0 +1,238 @@ +import { format, parseISO } from "date-fns"; +import { RRule, rrulestr } from "rrule"; +import type { Frequency, Weekday } from "@/lib/rfc5545-types"; + +export type SupportedRecurrenceFrequency = "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"; + +export interface RecurrenceFormValue { + freq: SupportedRecurrenceFrequency; + interval: number; + byDay: Weekday[]; + count?: number; + until?: string; +} + +export interface RecurrenceValidationResult { + isValid: boolean; + errors: Partial>; +} + +const EMPTY_RECURRENCE: RecurrenceFormValue = { + freq: "NONE", + interval: 1, + byDay: [], +}; + +const RULE_FREQUENCIES: Record, number> = { + DAILY: RRule.DAILY, + WEEKLY: RRule.WEEKLY, + MONTHLY: RRule.MONTHLY, +}; + +const WEEKDAY_ORDER: Weekday[] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; + +const RULE_WEEKDAYS = { + MO: RRule.MO, + TU: RRule.TU, + WE: RRule.WE, + TH: RRule.TH, + FR: RRule.FR, + SA: RRule.SA, + SU: RRule.SU, +}; + +const FREQUENCY_LABELS: Record = { + [RRule.DAILY]: "DAILY", + [RRule.WEEKLY]: "WEEKLY", + [RRule.MONTHLY]: "MONTHLY", +}; + +const SUPPORTED_FREQUENCIES = new Set([ + "NONE", + "DAILY", + "WEEKLY", + "MONTHLY", +]); + +const sortWeekdays = (days: string[] | undefined): Weekday[] => { + if (!days?.length) return []; + return [...days] + .filter((day): day is Weekday => WEEKDAY_ORDER.includes(day as Weekday)) + .sort((left, right) => WEEKDAY_ORDER.indexOf(left) - WEEKDAY_ORDER.indexOf(right)); +}; + +const toUntilDate = (until: string): Date => { + const parsed = parseISO(`${until}T23:59:59`); + return parsed; +}; + +const toRRuleOptions = (value: RecurrenceFormValue, dtstart?: string) => { + if (value.freq === "NONE") return null; + + const options: ConstructorParameters[0] = { + freq: RULE_FREQUENCIES[value.freq], + interval: value.interval, + }; + + if (dtstart) { + options.dtstart = parseISO(dtstart); + } + + if (value.freq === "WEEKLY" && value.byDay.length > 0) { + options.byweekday = value.byDay.map((day) => RULE_WEEKDAYS[day]); + } + + if (value.count) options.count = value.count; + if (value.until) options.until = toUntilDate(value.until); + + return options; +}; + +export const getDefaultRecurrence = (): RecurrenceFormValue => ({ + ...EMPTY_RECURRENCE, +}); + +export const validateRecurrence = ( + value: RecurrenceFormValue, +): RecurrenceValidationResult => { + const errors: RecurrenceValidationResult["errors"] = {}; + + if (!SUPPORTED_FREQUENCIES.has(value.freq)) { + errors.rule = "Unsupported recurrence frequency."; + } + + if (!Number.isInteger(value.interval) || value.interval < 1) { + errors.rule = "Interval must be at least 1."; + } + + if (value.count !== undefined && (!Number.isInteger(value.count) || value.count < 1)) { + errors.count = "Count must be a whole number greater than 0."; + } + + if (value.until) { + const parsed = parseISO(value.until); + if (Number.isNaN(parsed.getTime())) { + errors.until = "Until must be a valid date."; + } + } + + if (value.count !== undefined && value.until) { + errors.rule = "Choose either count or until, not both."; + } + + if (value.freq === "WEEKLY" && value.byDay.length === 0) { + errors.rule = "Choose at least one weekday for weekly recurrence."; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +}; + +export const parseRecurrenceRule = (rule?: string): RecurrenceFormValue => { + if (!rule) return getDefaultRecurrence(); + + const parsed = rrulestr(`RRULE:${rule}`) as RRule; + const options = parsed.origOptions; + const frequency = FREQUENCY_LABELS[options.freq ?? -1] ?? "NONE"; + + if (!SUPPORTED_FREQUENCIES.has(frequency)) { + return getDefaultRecurrence(); + } + + const byweekday = Array.isArray(options.byweekday) + ? options.byweekday.map((weekday) => weekday.toString()) + : options.byweekday + ? [options.byweekday.toString()] + : []; + + return { + freq: frequency, + interval: options.interval ?? 1, + byDay: sortWeekdays(byweekday), + count: options.count ?? undefined, + until: options.until ? format(options.until, "yyyy-MM-dd") : undefined, + }; +}; + +export const serializeRecurrenceRule = ( + value: RecurrenceFormValue, + dtstart?: string, +): string | undefined => { + if (value.freq === "NONE") return undefined; + + const validation = validateRecurrence(value); + if (!validation.isValid) { + throw new Error(validation.errors.rule || validation.errors.count || validation.errors.until || "Invalid recurrence."); + } + + const options = toRRuleOptions(value, dtstart); + if (!options) return undefined; + + const rule = new RRule(options); + const ruleLine = rule.toString().split("\n").find((line) => line.startsWith("RRULE:")); + if (!ruleLine) return undefined; + + const entries = ruleLine.replace(/^RRULE:/, "").split(";"); + const orderedKeys = ["FREQ", "INTERVAL", "UNTIL", "COUNT", "BYDAY"]; + const sorted = [...entries].sort((left, right) => { + const leftKey = left.split("=")[0] ?? ""; + const rightKey = right.split("=")[0] ?? ""; + const leftIndex = orderedKeys.indexOf(leftKey); + const rightIndex = orderedKeys.indexOf(rightKey); + const normalizedLeftIndex = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex; + const normalizedRightIndex = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex; + return normalizedLeftIndex - normalizedRightIndex || leftKey.localeCompare(rightKey); + }); + + return sorted.join(";"); +}; + +export const getRecurrencePreview = ( + value: RecurrenceFormValue, + dtstart?: string, + limit = 3, +): string[] => { + if (!dtstart || value.freq === "NONE") return []; + + const options = toRRuleOptions(value, dtstart); + if (!options) return []; + + const validation = validateRecurrence(value); + if (!validation.isValid) return []; + + const rule = new RRule(options); + return rule + .all((_, index) => index < limit) + .map((date) => date.toISOString()); +}; + +export const formatRecurrenceText = (rule?: string): string | null => { + if (!rule) return null; + return rrulestr(`RRULE:${rule}`).toText(); +}; + +export const getWeekdayOptions = (): Array<{ value: Weekday; label: string }> => [ + { value: "MO", label: "Mon" }, + { value: "TU", label: "Tue" }, + { value: "WE", label: "Wed" }, + { value: "TH", label: "Thu" }, + { value: "FR", label: "Fri" }, + { value: "SA", label: "Sat" }, + { value: "SU", label: "Sun" }, +]; + +export const isWeekdayPreset = (value: RecurrenceFormValue): boolean => + value.freq === "WEEKLY" && + value.interval === 1 && + value.byDay.join(",") === ["MO", "TU", "WE", "TH", "FR"].join(","); + +export const recurrenceFrequencyLabels: Record = { + NONE: "Does not repeat", + DAILY: "Daily", + WEEKLY: "Weekly", + MONTHLY: "Monthly", +}; + +export type { Frequency, Weekday }; diff --git a/tests/recurrence.test.ts b/tests/recurrence.test.ts new file mode 100644 index 0000000..9f749c5 --- /dev/null +++ b/tests/recurrence.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + formatRecurrenceText, + getRecurrencePreview, + parseRecurrenceRule, + serializeRecurrenceRule, + validateRecurrence, +} from "@/lib/recurrence"; + +describe("recurrence helpers", () => { + test("serializes a weekly weekday recurrence into an RFC5545 RRULE string", () => { + const rule = serializeRecurrenceRule({ + freq: "WEEKLY", + interval: 2, + byDay: ["TU", "TH"], + until: "2026-06-30", + }); + + expect(rule).toBe("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260630T235959Z;BYDAY=TU,TH"); + }); + + test("rehydrates an existing RRULE string into picker-friendly values", () => { + const parsed = parseRecurrenceRule( + "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR;COUNT=6", + ); + + expect(parsed).toEqual({ + freq: "WEEKLY", + interval: 1, + byDay: ["MO", "WE", "FR"], + count: 6, + until: undefined, + }); + }); + + test("returns a preview of upcoming recurrence instances from the public helper", () => { + const preview = getRecurrencePreview( + { + freq: "MONTHLY", + interval: 1, + byDay: [], + count: 3, + }, + "2026-04-09T10:00:00.000Z", + ); + + expect(preview).toHaveLength(3); + expect(preview[0]).toContain("2026-04-09T10:00:00.000Z"); + expect(preview[1]).toContain("2026-05-09T10:00:00.000Z"); + expect(preview[2]).toContain("2026-06-09T10:00:00.000Z"); + }); + + test("rejects invalid recurrence endings that mix count and until", () => { + const result = validateRecurrence({ + freq: "DAILY", + interval: 1, + byDay: [], + count: 5, + until: "2026-05-01", + }); + + expect(result.isValid).toBe(false); + expect(result.errors.rule).toContain("either count or until"); + }); + + test("rejects malformed count inputs", () => { + const result = validateRecurrence({ + freq: "MONTHLY", + interval: 1, + byDay: [], + count: 0, + }); + + expect(result.isValid).toBe(false); + expect(result.errors.count).toContain("greater than 0"); + }); + + test("formats supported recurrence strings into human text", () => { + expect(formatRecurrenceText("FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE")).toContain( + "every week", + ); + }); +});