"use client"; import { useState } from "react"; import { useLocale, useTranslations } from "next-intl"; import { addYears, format } from "date-fns"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { CalendarRecurrenceRule } from "@/lib/jmap/types"; type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly"; type MonthlyMode = "day" | "nth"; type EndsMode = "never" | "on" | "after"; const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"]; // 2024-01-01 is a Monday - used to render localized weekday names via Intl. const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"]; const DAY_TO_REF_DATE: Record = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 }; const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"]; const UNIT_LABEL_KEYS: Record = { daily: "recurrence.editor_unit_days", weekly: "recurrence.editor_unit_weeks", monthly: "recurrence.editor_unit_months", yearly: "recurrence.editor_unit_years", }; type CalendarT = ReturnType; function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string { const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1); return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref); } function monthName(month: number, locale: string): string { return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1)); } function nthLabel(nth: number, t: CalendarT): string { if (nth === -1) return t("recurrence.nth_last"); if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`); return String(nth); } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } /** * Extract an "nth weekday" pattern from a rule, accepting both the * byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding. */ function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null { if (rule.byDay?.length === 1) { const nd = rule.byDay[0]; if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod }; if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] }; } return null; } /** * True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly * dropdown presets produce, i.e. it needs no custom editor to represent. */ export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean { return ( (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) && (!rule.interval || rule.interval === 1) && !rule.byDay?.length && !rule.byMonthDay?.length && !rule.byMonth?.length && !rule.byYearDay?.length && !rule.byWeekNo?.length && !rule.bySetPosition?.length && !rule.count && !rule.until ); } /** * Human-readable summary of a recurrence rule, e.g. * "Every 2 months on the third Thursday · 12 occurrences". * Returns null for frequencies the UI cannot describe (hourly etc.). */ export function buildRecurrenceSummary( rule: CalendarRecurrenceRule, t: CalendarT, locale: string, ): string | null { const interval = rule.interval || 1; let base: string; switch (rule.frequency) { case "daily": base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily"); break; case "weekly": base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly"); break; case "monthly": base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly"); break; case "yearly": base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly"); break; default: return null; } const parts = [base]; if (rule.frequency === "weekly" && rule.byDay?.length) { const days = rule.byDay .filter((d) => WEEKDAYS.includes(d.day)) .sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day)) .map((d) => weekdayName(d.day, locale, "short")) .join(", "); if (days) parts.push(t("recurrence.on_days", { days })); } if (rule.frequency === "monthly" || rule.frequency === "yearly") { if (rule.frequency === "yearly" && rule.byMonth?.length) { const m = parseInt(rule.byMonth[0], 10); if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) })); } const nthDay = getNthDay(rule); if (nthDay) { parts.push(t("recurrence.on_the_nth", { nth: nthLabel(nthDay.nth, t), day: weekdayName(nthDay.day, locale), })); } else if (rule.byMonthDay?.length) { parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] })); } } let summary = parts.join(" "); if (rule.count) { summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`; } else if (rule.until) { const d = new Date(rule.until); if (!isNaN(d.getTime())) { summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`; } } return summary; } interface RecurrenceEditorProps { rule: CalendarRecurrenceRule | null; eventStart: Date; onSave: (rule: CalendarRecurrenceRule) => void; onCancel: () => void; } export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { const t = useTranslations("calendar"); const locale = useLocale(); const startDay = INDEX_TO_DAY[eventStart.getDay()]; const initialNthDay = rule ? getNthDay(rule) : null; const [frequency, setFrequency] = useState(() => rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) ? (rule.frequency as EditorFrequency) : "weekly" ); const [interval, setIntervalValue] = useState(rule?.interval || 1); const [weekDays, setWeekDays] = useState(() => { if (rule?.frequency === "weekly" && rule.byDay?.length) { const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d)); if (days.length) return days; } return [startDay]; }); const [monthlyMode, setMonthlyMode] = useState(initialNthDay ? "nth" : "day"); const [monthDay, setMonthDay] = useState(() => { const md = rule?.byMonthDay?.[0]; return md && md >= 1 && md <= 31 ? md : eventStart.getDate(); }); const [nth, setNth] = useState(() => { if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) { return initialNthDay.nth; } return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1); }); const [nthDay, setNthDay] = useState(() => initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay ); const [month, setMonth] = useState(() => { const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN; return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1; }); const [endsMode, setEndsMode] = useState(rule?.count ? "after" : rule?.until ? "on" : "never"); const [untilDate, setUntilDate] = useState(() => { if (rule?.until) { const d = new Date(rule.until); if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd"); } return format(addYears(eventStart, 1), "yyyy-MM-dd"); }); const [count, setCount] = useState(rule?.count ?? 12); const toggleWeekDay = (day: string) => { setWeekDays((prev) => prev.includes(day) ? prev.length > 1 ? prev.filter((d) => d !== day) : prev : [...prev, day] ); }; const buildRule = (): CalendarRecurrenceRule => { const built: CalendarRecurrenceRule = { "@type": "RecurrenceRule", frequency, interval: Math.max(1, interval), rscale: "gregorian", skip: "omit", firstDayOfWeek: "mo", byDay: null, byMonthDay: null, byMonth: null, byYearDay: null, byWeekNo: null, byHour: null, byMinute: null, bySecond: null, bySetPosition: null, count: endsMode === "after" ? Math.max(1, count) : null, until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null, }; if (frequency === "weekly") { const days = weekDays.length ? weekDays : [startDay]; built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day })); } else if (frequency === "monthly" || frequency === "yearly") { if (monthlyMode === "nth") { built.byDay = [{ day: nthDay, nthOfPeriod: nth }]; } else { built.byMonthDay = [Math.min(31, Math.max(1, monthDay))]; } if (frequency === "yearly") { built.byMonth = [String(month)]; } } return built; }; const handleSave = () => onSave(buildRule()); const summary = buildRecurrenceSummary(buildRule(), t, locale); const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; return (
{t("recurrence.editor_every")} { const n = parseInt(e.target.value, 10); setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1); }} className="w-16 shrink-0" aria-label={t("recurrence.editor_every")} />
{frequency === "weekly" && (
{WEEKDAYS.map((day) => ( ))}
)} {frequency === "yearly" && (
{capitalize(t("recurrence.editor_in"))}
)} {(frequency === "monthly" || frequency === "yearly") && (
{monthlyMode === "day" ? ( { const n = parseInt(e.target.value, 10); setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1); }} className="w-16 shrink-0" aria-label={t("recurrence.editor_on_day")} /> ) : ( <> )}
)}
{t("recurrence.editor_ends")} {endsMode === "on" && ( setUntilDate(e.target.value)} className={`${selectCls} flex-1 min-w-0`} aria-label={t("recurrence.editor_ends_on")} /> )} {endsMode === "after" && ( <> { const n = parseInt(e.target.value, 10); setCount(Number.isFinite(n) ? Math.max(1, n) : 1); }} className="w-16 shrink-0" aria-label={t("recurrence.editor_ends_after")} /> {t("recurrence.editor_occurrences")} )}

{summary}

); }