feat: recurrence editor, set-default calendar, and timezone-aware calendar queries

This commit is contained in:
Linus Rath
2026-06-11 19:05:11 +02:00
parent be58cee989
commit 08f344403b
26 changed files with 1519 additions and 62 deletions
+21 -2
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -96,6 +96,7 @@ export function CalendarSidebarPanel({
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
@@ -219,6 +220,16 @@ export function CalendarSidebarPanel({
}
};
const handleSetDefault = async (calendarId: string) => {
if (!client) return;
try {
await setDefaultCalendar(client, calendarId);
toast.success(tMgmt('default_updated'));
} catch {
toast.error(tMgmt('error_default'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
const renderCalendarItem = (cal: Calendar) => {
@@ -306,12 +317,13 @@ export function CalendarSidebarPanel({
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
const canChangeColor = !!onColorChange;
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
const canResetColor = !!onResetColor && hasColorOverride;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
@@ -330,6 +342,13 @@ export function CalendarSidebarPanel({
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canSetDefault && (
<ContextMenuItem
icon={Star}
label={tMgmt('set_default')}
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
+6 -11
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import {
isOrganizer,
getUserParticipantId,
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventDetailPopover({
@@ -130,6 +124,7 @@ export function EventDetailPopover({
isMobile,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
@@ -158,7 +153,7 @@ export function EventDetailPopover({
}, [event.virtualLocations]);
const participants = useMemo(() => getParticipantList(event), [event]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const userIsOrganizer = useMemo(() => {
+85 -27
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
return dur;
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventModal({
@@ -186,6 +180,7 @@ export function EventModal({
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
@@ -270,8 +265,35 @@ export function EventModal({
});
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
});
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
if (!event?.recurrenceRules?.length) return null;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? null : rule;
});
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
setCustomRule(rule);
setRecurrence("custom");
setShowRecurrenceEditor(false);
}, []);
const handleRecurrenceEditorCancel = useCallback(() => {
setShowRecurrenceEditor(false);
if (!customRule) {
setRecurrence(recurrenceBeforeCustomRef.current);
}
}, [customRule]);
const customRuleSummary = useMemo(
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
@@ -444,7 +466,9 @@ export function EventModal({
data.virtualLocations = null;
}
if (recurrence !== "none") {
if (recurrence === "custom" && customRule) {
data.recurrenceRules = [customRule];
} else if (recurrence !== "none" && recurrence !== "custom") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
frequency: recurrence,
@@ -511,7 +535,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -737,7 +761,7 @@ export function EventModal({
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
const recurrenceLabel = getRecurrenceLabel(event, t);
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
const alertLabel = getAlertLabel(event, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
@@ -1131,17 +1155,51 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
<div className="flex items-center gap-2">
<select
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
if (value === "custom") {
recurrenceBeforeCustomRef.current = recurrence;
setRecurrence("custom");
setShowRecurrenceEditor(true);
} else {
setRecurrence(value);
setShowRecurrenceEditor(false);
}
}}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
</select>
{recurrence === "custom" && !showRecurrenceEditor && (
<button
type="button"
onClick={() => setShowRecurrenceEditor(true)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("recurrence.edit_custom")}
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
{showRecurrenceEditor && (
<RecurrenceEditor
rule={customRule}
eventStart={(() => {
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
return isNaN(d.getTime()) ? new Date() : d;
})()}
onSave={handleRecurrenceEditorSave}
onCancel={handleRecurrenceEditorCancel}
/>
)}
</div>
<div>
+471
View File
@@ -0,0 +1,471 @@
"use client";
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { X } from "lucide-react";
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<string, number> = { 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 FREQ_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_freq_day",
weekly: "recurrence.editor_freq_week",
monthly: "recurrence.editor_freq_month",
yearly: "recurrence.editor_freq_year",
};
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_unit_days",
weekly: "recurrence.editor_unit_weeks",
monthly: "recurrence.editor_unit_months",
yearly: "recurrence.editor_unit_years",
};
type CalendarT = ReturnType<typeof useTranslations>;
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<EditorFrequency>(() =>
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
? (rule.frequency as EditorFrequency)
: "weekly"
);
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
const [weekDays, setWeekDays] = useState<string[]>(() => {
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<MonthlyMode>(initialNthDay ? "nth" : "day");
const [monthDay, setMonthDay] = useState<number>(() => {
const md = rule?.byMonthDay?.[0];
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
});
const [nth, setNth] = useState<number>(() => {
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<string>(() =>
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
);
const [month, setMonth] = useState<number>(() => {
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
});
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
const [untilDate, setUntilDate] = useState<string>(() => {
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<number>(rule?.count ?? 12);
const toggleWeekDay = (day: string) => {
setWeekDays((prev) =>
prev.includes(day)
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
: [...prev, day]
);
};
const handleSave = () => {
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)];
}
}
onSave(built);
};
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50";
return (
<div className="mt-2 rounded-md border border-border bg-muted/20 p-4 space-y-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap gap-1.5">
{EDITOR_FREQUENCIES.map((f) => (
<button
key={f}
type="button"
onClick={() => setFrequency(f)}
className={
frequency === f
? "px-3 py-1.5 text-sm rounded-md border border-primary text-primary bg-primary/10"
: "px-3 py-1.5 text-sm rounded-md border border-input text-foreground hover:bg-muted transition-colors"
}
>
{t(FREQ_LABEL_KEYS[f])}
</button>
))}
</div>
<button
type="button"
onClick={onCancel}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("form.cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
<p className="text-sm font-medium">{t("recurrence.editor_repeats_on")}</p>
<div className="flex items-center gap-2 text-sm">
<span>{t("recurrence.editor_every")}</span>
<Input
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-20"
aria-label={t("recurrence.editor_every")}
/>
<span>{t(UNIT_LABEL_KEYS[frequency])}</span>
</div>
{frequency === "weekly" && (
<div className="flex flex-wrap gap-1.5">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekDay(day)}
title={weekdayName(day, locale)}
className={
weekDays.includes(day)
? "px-2.5 py-1.5 text-sm rounded-md border border-primary text-primary bg-primary/10"
: "px-2.5 py-1.5 text-sm rounded-md border border-input text-foreground hover:bg-muted transition-colors"
}
>
{weekdayName(day, locale, "short")}
</button>
))}
</div>
)}
{frequency === "yearly" && (
<div className="flex items-center gap-2 text-sm">
<span>{t("recurrence.editor_in")}</span>
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
className={selectCls}
aria-label={t("recurrence.editor_in")}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
))}
</select>
</div>
)}
{(frequency === "monthly" || frequency === "yearly") && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="recurrence-monthly-mode"
checked={monthlyMode === "day"}
onChange={() => setMonthlyMode("day")}
className="border-input"
/>
<span>{t("recurrence.editor_on_day")}</span>
<Input
type="number"
min={1}
max={31}
value={monthDay}
disabled={monthlyMode !== "day"}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
}}
className="w-20"
aria-label={t("recurrence.editor_on_day")}
/>
</label>
<label className="flex flex-wrap items-center gap-2 text-sm">
<input
type="radio"
name="recurrence-monthly-mode"
checked={monthlyMode === "nth"}
onChange={() => setMonthlyMode("nth")}
className="border-input"
/>
<span>{t("recurrence.editor_on_the")}</span>
<select
value={nth}
disabled={monthlyMode !== "nth"}
onChange={(e) => setNth(parseInt(e.target.value, 10))}
className={selectCls}
aria-label={t("recurrence.editor_on_the")}
>
{[1, 2, 3, 4, -1].map((n) => (
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
))}
</select>
<select
value={nthDay}
disabled={monthlyMode !== "nth"}
onChange={(e) => setNthDay(e.target.value)}
className={selectCls}
aria-label={t("recurrence.editor_on_the")}
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
))}
</select>
</label>
</div>
)}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t("recurrence.editor_ends")}</p>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="recurrence-ends-mode"
checked={endsMode === "never"}
onChange={() => setEndsMode("never")}
className="border-input"
/>
<span>{t("recurrence.editor_never")}</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="recurrence-ends-mode"
checked={endsMode === "on"}
onChange={() => setEndsMode("on")}
className="border-input"
/>
<span>{t("recurrence.editor_ends_on")}</span>
<input
type="date"
value={untilDate}
disabled={endsMode !== "on"}
onChange={(e) => setUntilDate(e.target.value)}
className={selectCls}
aria-label={t("recurrence.editor_ends_on")}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="recurrence-ends-mode"
checked={endsMode === "after"}
onChange={() => setEndsMode("after")}
className="border-input"
/>
<span>{t("recurrence.editor_ends_after")}</span>
<Input
type="number"
min={1}
max={999}
value={count}
disabled={endsMode !== "after"}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-20"
aria-label={t("recurrence.editor_ends_after")}
/>
<span>{t("recurrence.editor_occurrences")}</span>
</label>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={onCancel}>
{t("form.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("form.save")}
</Button>
</div>
</div>
);
}