- helper text condition now uses (allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS) - previously checked !TIMED && !ALLDAY: a timed event with 10080 (in ALLDAY set) was incorrectly treated as 'in presets' and suppressed the helper text - synthetic option gating for each allDay/timed branch was already correct
1239 lines
50 KiB
TypeScript
1239 lines
50 KiB
TypeScript
/**
|
|
* EventForm — create/edit event modal overlay (Plan 03-05).
|
|
*
|
|
* Rendering modes:
|
|
* - create: "New Event" form opened via FAB/toolbar (eventFormMode === 'create')
|
|
* - edit: pre-populated from TanStack Query cache by eventFormUid
|
|
*
|
|
* Responsive:
|
|
* - Phone (≤767px): bottom sheet (full-screen overlay)
|
|
* - Tablet/desktop (≥768px): centered dialog (max-width 480px)
|
|
*
|
|
* Security: T-03-15 — all field values rendered as plain-text JSX children.
|
|
* NEVER use dangerouslySetInnerHTML for any event field.
|
|
*
|
|
* Calendar picker: hidden when member has exactly 1 writable calendar (D-02).
|
|
* Populated from GET /api/events/writable-calendars (D-03 server-authoritative).
|
|
*
|
|
* Writes: 202 optimistic-accept (D-05/D-12). Enqueued to outbox by the API.
|
|
* On success: form closes. SyncStateToast feedback lands in Plan 03-06.
|
|
*
|
|
* Accessibility:
|
|
* - role="dialog", aria-modal="true", aria-label="New Event"/"Edit Event"
|
|
* - Focus moves to Title input on open
|
|
* - Focus trap: Tab/Shift+Tab cycle focus within dialog; never reaches background (WR-07)
|
|
* - Escape / backdrop click closes form
|
|
* - All-day toggle: role="switch", aria-checked
|
|
* - Recurrence: <select> with labeled options
|
|
* - 44px minimum touch targets on all interactive elements
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { X, Loader2 } from 'lucide-react';
|
|
import { useCalendarStore, todayIso } from '../store/calendarStore.js';
|
|
import {
|
|
createEvent,
|
|
updateEvent,
|
|
fetchWritableCalendars,
|
|
type CreateEventPayload,
|
|
type RecurrencePreset,
|
|
} from '../api/client.js';
|
|
import type { CalendarOccurrence } from '../api/client.js';
|
|
import {
|
|
serializeEventDateTime,
|
|
computeNewTimedEnd,
|
|
computeNewAllDayEnd,
|
|
} from '../lib/eventDateTime.js';
|
|
import { SeriesEditPrompt } from './SeriesEditPrompt.js';
|
|
import { useFocusTrap } from '../hooks/useFocusTrap.js';
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl';
|
|
|
|
// Phase 11: reminder preset values (minutes) for each event type.
|
|
// Used for edit-mode classification and rendering.
|
|
const TIMED_REMINDER_PRESETS = new Set([5, 10, 15, 30, 60, 120, 1440, 2880]);
|
|
const ALLDAY_REMINDER_PRESETS = new Set([0, 1440, 2880, 10080]);
|
|
|
|
// IN-03: todayIso is now imported from calendarStore (single source of truth).
|
|
// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites.
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Humanize an off-list reminder lead (minutes) for the synthetic picker option label.
|
|
* Per UI-SPEC Copywriting Contract thresholds:
|
|
* < 60 min → "N min before"
|
|
* ≥ 60 min → "N hours before"
|
|
*/
|
|
function humanizeReminderLead(minutes: number): string {
|
|
if (minutes < 60) return `${minutes} min before`;
|
|
const hours = minutes / 60;
|
|
return `${hours} hour${hours !== 1 ? 's' : ''} before`;
|
|
}
|
|
|
|
/**
|
|
* Derive the initial reminder picker value from an occurrence's reminder fields.
|
|
*
|
|
* CR-01 (Plan 11-05): When reminderIsCustom is true, the event carries an absolute
|
|
* DATE-TIME trigger or multiple VALARMs that cannot be reduced to a single lead.
|
|
* Returning '__custom__' makes the existing preserve branch live: the form emits an
|
|
* absent reminderLeadMinutes field, and the outboxWorker's D-08 preserve path keeps
|
|
* the original VALARM intact (no silent data loss on edit).
|
|
*
|
|
* Precedence: custom → '__custom__'; null → '__none__'; else preset/off-list string.
|
|
*/
|
|
function deriveReminderValue(
|
|
leadMinutes: number | null,
|
|
isAllDay: boolean,
|
|
isCustom = false,
|
|
): string {
|
|
if (isCustom) return '__custom__';
|
|
if (leadMinutes === null) return '__none__';
|
|
const presets = isAllDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS;
|
|
if (presets.has(leadMinutes)) return String(leadMinutes);
|
|
// Off-list positive value — use the numeric string; a synthetic option will be rendered
|
|
return String(leadMinutes);
|
|
}
|
|
|
|
/** Determine if we're on phone breakpoint. */
|
|
function isPhoneBreakpoint(): boolean {
|
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
|
}
|
|
|
|
/** Read last-used calendar URL from localStorage (D-01). */
|
|
function readLastCalendarUrl(): string | null {
|
|
try {
|
|
return localStorage.getItem(LAST_CALENDAR_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Persist last-used calendar URL (D-01). */
|
|
function writeLastCalendarUrl(url: string): void {
|
|
try {
|
|
localStorage.setItem(LAST_CALENDAR_KEY, url);
|
|
} catch {
|
|
// Ignore write failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CR-03: convert an EXCLUSIVE all-day end date ('YYYY-MM-DD') to the INCLUSIVE
|
|
* last day the form displays. The occurrence/expand contract keeps all-day ends
|
|
* exclusive (matching the DTEND Fastmail stores, RFC-5545 §3.6.1), and
|
|
* buildVeventString re-advances the inclusive form value by one day on write.
|
|
* Without this subtraction, round-tripping an edit re-advances an already-exclusive
|
|
* end, silently growing multi-day all-day events by one day per save. Parses by
|
|
* UTC components so the roll-back is DST-safe (mirrors vevent.ts's roll-forward).
|
|
*/
|
|
function exclusiveEndToInclusiveDate(dateStr: string): string {
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
|
if (!m) return dateStr;
|
|
const [, y, mo, d] = m;
|
|
const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
|
|
date.setUTCDate(date.getUTCDate() - 1);
|
|
const yy = String(date.getUTCFullYear());
|
|
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
return `${yy}-${mm}-${dd}`;
|
|
}
|
|
|
|
/**
|
|
* Parse an ISO date string (possibly with time + offset) into
|
|
* { date: 'YYYY-MM-DD', time: 'HH:MM' }. Falls back to today/09:00 if malformed.
|
|
*
|
|
* WR-05 fix: date and time are derived from the SAME local-accessor family.
|
|
* NEVER mix toISOString().slice(0,10) (UTC date) with getHours() (local time).
|
|
* Both date and time use getFullYear/getMonth/getDate/getHours/getMinutes so
|
|
* the pair describes the same wall-clock consistently in the viewer's zone.
|
|
*/
|
|
function parseDateTime(iso: string): { date: string; time: string; ok: boolean } {
|
|
try {
|
|
// Strip IANA bracket suffix e.g. '[America/Toronto]'
|
|
const clean = iso.replace(/\[[^\]]*\]$/, '');
|
|
// WR-03: test and return `clean`, not the raw `iso`. Testing `iso` would let a
|
|
// date-only value carrying a bracket suffix skip the all-day branch and fall through
|
|
// to new Date(clean); using `clean` matches the "strip IANA suffix" intent above.
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(clean)) {
|
|
// All-day date string — use as-is (no time component)
|
|
return { date: clean, time: '09:00', ok: true };
|
|
}
|
|
// WR-08: require a well-formed timed shape (date + 'T' + HH:MM) before trusting
|
|
// new Date()'s permissive parsing. Otherwise a truncated/garbled cached value like
|
|
// '2026-06' parses as a VALID UTC instant in V8 and would be saved on edit without
|
|
// tripping the IN-02 blank-field guard. A genuinely malformed timed value now fails
|
|
// here and is reported ok:false instead of silently resolving to an unintended day.
|
|
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(clean)) {
|
|
throw new Error('Malformed timed datetime');
|
|
}
|
|
const d = new Date(clean);
|
|
if (isNaN(d.getTime())) throw new Error('Invalid date');
|
|
// WR-05: use ONLY local accessors so date and time are in the same zone frame.
|
|
// Do NOT use toISOString() here — it returns UTC, which can differ from local time.
|
|
const year = String(d.getFullYear());
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
const hours = String(d.getHours()).padStart(2, '0');
|
|
const mins = String(d.getMinutes()).padStart(2, '0');
|
|
return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}`, ok: true };
|
|
} catch {
|
|
// IN-02: signal failure so the CREATE path can fall back to today/09:00 (a benign
|
|
// default for a brand-new event) while the EDIT path leaves the field blank and
|
|
// blocks submit — never silently rewriting a corrupt cached value to today/09:00.
|
|
return { date: todayIso(), time: '09:00', ok: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* IN-02: resolve the form's initial date/time for a pre-filled occurrence value.
|
|
* In CREATE mode (or when there is no occurrence) a parse failure falls back to the
|
|
* benign today/09:00 default. In EDIT mode a parse failure leaves the field BLANK so
|
|
* the user sees the value did not load and submit is blocked (validate() treats a blank
|
|
* start/end as invalid), rather than silently substituting today/09:00 and saving it.
|
|
*/
|
|
function initFormDateTime(
|
|
iso: string | undefined,
|
|
isEdit: boolean,
|
|
fallbackTime: string,
|
|
): { date: string; time: string } {
|
|
if (iso === undefined) return { date: todayIso(), time: fallbackTime };
|
|
const parsed = parseDateTime(iso);
|
|
if (parsed.ok) return { date: parsed.date, time: parsed.time };
|
|
// Parse failed
|
|
if (isEdit) return { date: '', time: '' };
|
|
return { date: todayIso(), time: fallbackTime };
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
export function EventForm() {
|
|
const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore();
|
|
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid);
|
|
const queryClient = useQueryClient();
|
|
const titleRef = useRef<HTMLInputElement>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
// ── Resolve event for edit mode ─────────────────────────────────────────────
|
|
|
|
const occurrence: CalendarOccurrence | null = (() => {
|
|
if (eventFormMode !== 'edit' || !eventFormUid) return null;
|
|
const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
|
|
queryKey: ['events'],
|
|
});
|
|
for (const [, data] of allEntries) {
|
|
if (!data?.occurrences) continue;
|
|
const found = data.occurrences.find((o) => o.uid === eventFormUid || o.id === eventFormUid);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
// ── Writable calendars (D-03) ───────────────────────────────────────────────
|
|
|
|
const { data: writableCalendars = [] } = useQuery({
|
|
queryKey: ['writableCalendars'],
|
|
queryFn: fetchWritableCalendars,
|
|
staleTime: 5 * 60 * 1000,
|
|
enabled: eventFormOpen,
|
|
});
|
|
|
|
// ── Form state ──────────────────────────────────────────────────────────────
|
|
|
|
const isEditMode = eventFormMode === 'edit' && !!eventFormUid;
|
|
const initStart = initFormDateTime(occurrence?.start, isEditMode, '09:00');
|
|
const initEnd = initFormDateTime(occurrence?.end, isEditMode, '10:00');
|
|
// CR-03: occurrence.end for an all-day event is the EXCLUSIVE DTEND; the form's
|
|
// end-date input is the INCLUSIVE last day. Convert when pre-filling so a re-edit
|
|
// does not re-advance the span (buildVeventString rolls forward again on write).
|
|
// IN-02: skip the roll-back when initEnd.date is blank (parse failure in edit mode).
|
|
const initEndDate =
|
|
occurrence?.allDay && initEnd.date ? exclusiveEndToInclusiveDate(initEnd.date) : initEnd.date;
|
|
|
|
const [title, setTitle] = useState(occurrence?.title ?? '');
|
|
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
|
|
const [startDate, setStartDate] = useState(initStart.date);
|
|
const [startTime, setStartTime] = useState(initStart.time);
|
|
const [endDate, setEndDate] = useState(initEndDate);
|
|
const [endTime, setEndTime] = useState(initEnd.time);
|
|
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none');
|
|
// Phase 11: reminder picker state. Sentinel values:
|
|
// '__none__' = None (no reminder)
|
|
// '__custom__' = read-only "Custom (kept)" (absolute/multi VALARM, D-07/D-08)
|
|
// numeric str = preset or synthetic off-list lead in minutes
|
|
const [reminderValue, setReminderValue] = useState<string>('__none__');
|
|
// D-06: recurrence bound state — "Ends" control
|
|
const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never');
|
|
const [recurrenceUntil, setRecurrenceUntil] = useState('');
|
|
const [recurrenceCount, setRecurrenceCount] = useState(1);
|
|
const [location, setLocation] = useState(occurrence?.location ?? '');
|
|
const [description, setDescription] = useState(occurrence?.description ?? '');
|
|
const [calendarUrl, setCalendarUrl] = useState<string>(() => {
|
|
if (occurrence?.calendarId) {
|
|
// In edit mode we don't know the URL from occurrence, use last-used or first writable
|
|
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? '';
|
|
}
|
|
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? '';
|
|
});
|
|
|
|
// Sync calendarUrl default when writableCalendars loads
|
|
useEffect(() => {
|
|
if (writableCalendars.length > 0 && !calendarUrl) {
|
|
const lastUrl = readLastCalendarUrl();
|
|
const found = lastUrl ? writableCalendars.find((c) => c.url === lastUrl) : null;
|
|
setCalendarUrl(found ? found.url : writableCalendars[0].url);
|
|
}
|
|
}, [writableCalendars]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Reset form when opening (mode may change) or when occurrence resolves in cache.
|
|
// WR-03: `occurrence` (via occurrence?.uid) is in the dep array so the effect
|
|
// re-runs when the occurrence arrives in the TanStack cache after form open.
|
|
// This prevents the "blank edit form" bug when the form opens before the cache
|
|
// has hydrated the occurrence for the requested UID.
|
|
useEffect(() => {
|
|
if (eventFormOpen) {
|
|
const editMode = eventFormMode === 'edit' && !!eventFormUid;
|
|
const startParsed = initFormDateTime(occurrence?.start, editMode, '09:00');
|
|
const endParsed = initFormDateTime(occurrence?.end, editMode, '10:00');
|
|
// CR-03: see exclusiveEndToInclusiveDate — pre-fill the inclusive last day for
|
|
// all-day events so re-saving an edit does not grow the span by a day each time.
|
|
// IN-02: skip the roll-back when endParsed.date is blank (parse failure in edit mode).
|
|
const endDateValue =
|
|
occurrence?.allDay && endParsed.date
|
|
? exclusiveEndToInclusiveDate(endParsed.date)
|
|
: endParsed.date;
|
|
setTitle(occurrence?.title ?? '');
|
|
setAllDay(occurrence?.allDay ?? false);
|
|
setStartDate(startParsed.date);
|
|
setStartTime(startParsed.time);
|
|
setEndDate(endDateValue);
|
|
setEndTime(endParsed.time);
|
|
// WR-03 recurrence: derive from occurrence if present; default 'none' only when
|
|
// genuinely absent. Note: occurrence.recurrence is not in CalendarOccurrence type
|
|
// (the API expand contract does not expose it in v1 — D-03). We cast to any to
|
|
// read it if a future API version adds it, and default to 'none' when not present
|
|
// (WR-03 v1 comment: occurrence edits whose recurrence is not in the cache default
|
|
// to 'none'; this will be addressed when the occurrence/expand contract is extended).
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- occurrence.recurrence is not in CalendarOccurrence v1 type; cast to any to read a future API field, default 'none' when absent
|
|
const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined;
|
|
setRecurrence(derivedRecurrence ?? 'none');
|
|
// D-06: reset bound state to defaults on form open/occurrence change
|
|
setRecurrenceBound('never');
|
|
setRecurrenceUntil('');
|
|
setRecurrenceCount(1);
|
|
setLocation(occurrence?.location ?? '');
|
|
setDescription(occurrence?.description ?? '');
|
|
// Phase 11: derive reminder picker value from occurrence (edit-mode pre-population, D-01/D-07)
|
|
// CR-01 (Plan 11-05): pass reminderIsCustom so custom alarms initialize to '__custom__'
|
|
// instead of '__none__', making the D-08 preserve path reachable on edit.
|
|
const occAllDay = occurrence?.allDay ?? false;
|
|
const occIsCustom = occurrence?.reminderIsCustom ?? false;
|
|
setReminderValue(
|
|
deriveReminderValue(occurrence?.reminderLeadMinutes ?? null, occAllDay, occIsCustom),
|
|
);
|
|
}
|
|
}, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// ── Validation state ────────────────────────────────────────────────────────
|
|
|
|
const [errors, setErrors] = useState<{
|
|
title?: string;
|
|
endTime?: string;
|
|
recurrenceBound?: string;
|
|
}>({});
|
|
// D-08/D-09: series-edit confirmation prompt state
|
|
const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false);
|
|
|
|
// ── Mutations ───────────────────────────────────────────────────────────────
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (payload: CreateEventPayload) =>
|
|
eventFormMode === 'edit' && eventFormUid
|
|
? updateEvent(eventFormUid, payload)
|
|
: createEvent(payload),
|
|
onSuccess: (data) => {
|
|
// Wire sync-toast: set the returned UID so SyncStateToast starts polling (D-05/D-09)
|
|
setLastSyncedUid(data.uid);
|
|
// Do NOT invalidate here — SyncStateToast does it on done/conflict (D-06/D-08)
|
|
if (calendarUrl) writeLastCalendarUrl(calendarUrl);
|
|
setEventForm(false);
|
|
},
|
|
});
|
|
|
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
|
|
const handleClose = () => setEventForm(false);
|
|
|
|
const handleAllDayToggle = () => {
|
|
const next = !allDay;
|
|
setAllDay(next);
|
|
// D-03 (Phase 11): reset reminder to None on allDay toggle — no carry-over between preset sets
|
|
setReminderValue('__none__');
|
|
if (!next) {
|
|
// Turning off all-day: restore default times
|
|
setStartTime('09:00');
|
|
setEndTime('10:00');
|
|
}
|
|
if (next) {
|
|
// WR-02: turning all-day ON discards the time inputs, so a midnight-spanning
|
|
// timed event (start 06-10 23:00, end 06-11 01:00) would otherwise leave endDate
|
|
// at 06-11 — a 2-day all-day span the user did not intend. Clamp endDate to
|
|
// max(startDate, endDate) deterministically: when the end day is behind the start
|
|
// it snaps forward to a single-day event; an already-valid multi-day all-day span
|
|
// is preserved. Also clear any stale end-time error left over from the timed view.
|
|
setEndDate((prev) => (prev < startDate ? startDate : prev));
|
|
setErrors((prev) => (prev.endTime ? { ...prev, endTime: undefined } : prev));
|
|
}
|
|
};
|
|
|
|
const validate = (): boolean => {
|
|
const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {};
|
|
|
|
if (!title.trim()) {
|
|
newErrors.title = 'Title is required';
|
|
}
|
|
|
|
// IN-02: a blank start/end date means a cached value failed to parse in edit mode
|
|
// (initFormDateTime left it empty rather than substituting today/09:00). Block submit
|
|
// so the corrupt value is never silently saved as today/09:00.
|
|
if (!startDate || !endDate || (!allDay && (!startTime || !endTime))) {
|
|
newErrors.endTime = "Couldn't read this event's date — re-open it from the calendar";
|
|
setErrors(newErrors);
|
|
return false;
|
|
}
|
|
|
|
if (!allDay) {
|
|
const startISO = `${startDate}T${startTime}:00`;
|
|
const endISO = `${endDate}T${endTime}:00`;
|
|
if (endISO <= startISO) {
|
|
newErrors.endTime = 'End time must be after start';
|
|
}
|
|
} else {
|
|
if (endDate < startDate) {
|
|
newErrors.endTime = 'End time must be after start';
|
|
}
|
|
}
|
|
|
|
// D-06: validate recurrence bound inputs (T-06-06-input: client-side UX gate)
|
|
if (recurrence !== 'none') {
|
|
if (
|
|
recurrenceBound === 'count' &&
|
|
(!Number.isInteger(recurrenceCount) || recurrenceCount < 1)
|
|
) {
|
|
// WR-03: NaN < 1 is false, so a NaN count (from inputs like '' / '-' / 'e')
|
|
// previously bypassed this guard AND the payload spread, yielding an unbounded
|
|
// series. Require a finite integer ≥ 1 explicitly.
|
|
newErrors.recurrenceBound = 'Must be at least 1 occurrence';
|
|
} else if (recurrenceBound === 'until') {
|
|
// WR-02: a blank end date with bound='until' must be a validation error.
|
|
// Previously it passed validation and the payload spread dropped
|
|
// recurrenceUntil, silently creating an UNBOUNDED series — the opposite
|
|
// of the user's stated "Ends: On date" intent.
|
|
if (!recurrenceUntil) {
|
|
newErrors.recurrenceBound = 'Choose an end date';
|
|
} else if (startDate && recurrenceUntil < startDate) {
|
|
// WR-07: only compare when startDate is non-empty. Both values are
|
|
// zero-padded ISO DATE strings here, so lexicographic compare is valid;
|
|
// guarding on a non-empty startDate avoids `recurrenceUntil < ''` (always
|
|
// false) silently skipping the bound-before-start guard.
|
|
newErrors.recurrenceBound = 'End date must be after the event starts';
|
|
}
|
|
}
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
/**
|
|
* Build the payload and fire the mutation. Called directly for non-recurring edits
|
|
* and create mode; called from the SeriesEditPrompt onConfirm for recurring edits
|
|
* (D-08/D-09). The whole-series PATCH reuses the existing /api/events/:uid/edit route
|
|
* which PUTs the master VEVENT — no RECURRENCE-ID, per D-08.
|
|
*/
|
|
const executeSubmit = () => {
|
|
// BUG A fix: serialize timed events to an unambiguous UTC instant here in
|
|
// the browser (operator's zone is known) instead of sending a naive local
|
|
// wall-clock string. The API container is UTC; a naive string was being read
|
|
// as UTC, shifting 09:00 local to 09:00Z (4h off). See lib/eventDateTime.ts.
|
|
const { start: serializedStart, end: serializedEnd } = serializeEventDateTime(
|
|
allDay,
|
|
startDate,
|
|
startTime,
|
|
endDate,
|
|
endTime,
|
|
);
|
|
|
|
// WR-01: on EDIT, omit `recurrence` from the payload. The occurrence/expand contract
|
|
// does not expose the event's existing recurrence (D-03), so the form cannot know it
|
|
// and would otherwise send 'none' — silently stripping the RRULE and converting a
|
|
// recurring series into a single event. Omitting the field signals "unchanged"; the
|
|
// outbox worker then preserves the stored RRULE (see outboxWorker.ts WR-01). On
|
|
// CREATE the user explicitly chose a recurrence, so it is always sent.
|
|
const isEdit = eventFormMode === 'edit' && !!eventFormUid;
|
|
|
|
// Phase 11: map reminder picker value to payload field (D-08).
|
|
// '__none__' → null (explicit clear)
|
|
// '__custom__' → omit (unchanged custom alarm — server preserves original VALARM)
|
|
// numeric str → integer (preset or synthetic off-list lead)
|
|
let reminderPayload: { reminderLeadMinutes?: number | null } = {};
|
|
if (reminderValue === '__none__') {
|
|
reminderPayload = { reminderLeadMinutes: null };
|
|
} else if (reminderValue !== '__custom__') {
|
|
const parsed = parseInt(reminderValue, 10);
|
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
reminderPayload = { reminderLeadMinutes: parsed };
|
|
}
|
|
// If parse fails (should not happen), omit field (safe default: no-change)
|
|
}
|
|
// __custom__ → reminderPayload stays {} (field absent = no-change, D-08)
|
|
|
|
const payload: CreateEventPayload = {
|
|
title: title.trim(),
|
|
allDay,
|
|
start: serializedStart,
|
|
end: serializedEnd,
|
|
...(isEdit ? {} : { recurrence }),
|
|
// D-06: only send bound fields when recurrence is set and bound type is not 'never'
|
|
...(!isEdit && recurrence !== 'none' && recurrenceBound === 'until' && recurrenceUntil
|
|
? { recurrenceUntil }
|
|
: {}),
|
|
...(!isEdit && recurrence !== 'none' && recurrenceBound === 'count' && recurrenceCount >= 1
|
|
? { recurrenceCount }
|
|
: {}),
|
|
...(location.trim() ? { location: location.trim() } : {}),
|
|
...(description.trim() ? { description: description.trim() } : {}),
|
|
...reminderPayload,
|
|
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
|
|
};
|
|
|
|
mutation.mutate(payload);
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
if (!validate()) return;
|
|
|
|
const isEdit = eventFormMode === 'edit' && !!eventFormUid;
|
|
|
|
// D-08/D-09: gate recurring-series edits behind the confirmation prompt
|
|
if (isEdit && occurrence?.hasRrule === true) {
|
|
setSeriesEditPromptOpen(true);
|
|
return;
|
|
}
|
|
|
|
executeSubmit();
|
|
};
|
|
|
|
// ── Keyboard: Escape to close ───────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (!eventFormOpen) return;
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') handleClose();
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => document.removeEventListener('keydown', onKeyDown);
|
|
}, [eventFormOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// ── Focus trap: Tab / Shift+Tab cycles within dialog (WR-07) ───────────────
|
|
// IN-05: shared with SeriesEditPrompt via useFocusTrap so the trap logic lives once.
|
|
const handleDialogKeyDown = useFocusTrap(dialogRef);
|
|
|
|
// ── Focus: move to title input on open ─────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (eventFormOpen && titleRef.current) {
|
|
titleRef.current.focus();
|
|
}
|
|
}, [eventFormOpen]);
|
|
|
|
// ── Early return ────────────────────────────────────────────────────────────
|
|
|
|
if (!eventFormOpen) return null;
|
|
|
|
// ── Responsive styles ───────────────────────────────────────────────────────
|
|
|
|
const isPhone = isPhoneBreakpoint();
|
|
const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event';
|
|
// D-08/D-09: CTA label is "Update series" for recurring edits (UI-SPEC "EventForm primary CTAs")
|
|
const isEditRecurring = eventFormMode === 'edit' && occurrence?.hasRrule === true;
|
|
const saveLabel = mutation.isPending
|
|
? 'Saving…'
|
|
: isEditRecurring
|
|
? 'Update series'
|
|
: eventFormMode === 'edit'
|
|
? 'Save Changes'
|
|
: 'Create Event';
|
|
|
|
const dialogStyle: React.CSSProperties = isPhone
|
|
? {
|
|
position: 'fixed',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
background: 'var(--color-surface-raised)',
|
|
borderRadius: 'var(--space-3) var(--space-3) 0 0',
|
|
boxShadow: '0 -4px 24px rgba(0,0,0,0.12)',
|
|
padding: 'var(--space-6)',
|
|
maxHeight: '90dvh',
|
|
overflowY: 'auto',
|
|
zIndex: 200,
|
|
fontFamily: 'var(--font-family-base)',
|
|
}
|
|
: {
|
|
position: 'fixed',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
background: 'var(--color-surface-raised)',
|
|
borderRadius: 'var(--space-2)',
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.16)',
|
|
padding: 'var(--space-6)',
|
|
width: '100%',
|
|
maxWidth: '480px',
|
|
maxHeight: '90dvh',
|
|
overflowY: 'auto',
|
|
zIndex: 200,
|
|
fontFamily: 'var(--font-family-base)',
|
|
};
|
|
|
|
// ── Shared input style ──────────────────────────────────────────────────────
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
minHeight: '44px',
|
|
padding: '0 var(--space-3)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--space-1)',
|
|
background: 'var(--color-surface)',
|
|
fontSize: 'var(--text-body-size)',
|
|
fontWeight: 'var(--text-body-weight)',
|
|
color: 'var(--color-text-primary)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
outline: 'none',
|
|
};
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
display: 'block',
|
|
fontSize: 'var(--text-label-size)',
|
|
fontWeight: 'var(--text-label-weight)',
|
|
color: 'var(--color-text-secondary)',
|
|
marginBottom: 'var(--space-1)',
|
|
};
|
|
|
|
const fieldStyle: React.CSSProperties = {
|
|
marginBottom: 'var(--space-4)',
|
|
};
|
|
|
|
const errorStyle: React.CSSProperties = {
|
|
fontSize: 'var(--text-label-size)',
|
|
color: 'var(--color-destructive)',
|
|
marginTop: 'var(--space-1)',
|
|
};
|
|
|
|
const showPicker = writableCalendars.length > 1;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
data-testid="event-form-backdrop"
|
|
onClick={handleClose}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'var(--color-overlay)',
|
|
zIndex: 199,
|
|
}}
|
|
/>
|
|
|
|
{/* Dialog */}
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={label}
|
|
tabIndex={-1}
|
|
onKeyDown={handleDialogKeyDown}
|
|
style={dialogStyle}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 'var(--space-4)',
|
|
}}
|
|
>
|
|
<h2
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 'var(--text-heading-size)',
|
|
fontWeight: 'var(--text-heading-weight)',
|
|
lineHeight: 'var(--text-heading-line-height)',
|
|
color: 'var(--color-text-primary)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
}}
|
|
>
|
|
{/* Plain text — XSS guard (T-03-15) */}
|
|
{label}
|
|
</h2>
|
|
<button
|
|
aria-label="Close"
|
|
onClick={handleClose}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
minWidth: '44px',
|
|
minHeight: '44px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'var(--color-text-secondary)',
|
|
borderRadius: 'var(--space-1)',
|
|
padding: 0,
|
|
}}
|
|
>
|
|
<X size={20} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Title field */}
|
|
<div style={fieldStyle}>
|
|
<label htmlFor="event-title" style={labelStyle}>
|
|
Title
|
|
</label>
|
|
<input
|
|
id="event-title"
|
|
ref={titleRef}
|
|
type="text"
|
|
placeholder="Event title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
style={{
|
|
...inputStyle,
|
|
...(errors.title ? { borderColor: 'var(--color-destructive)' } : {}),
|
|
}}
|
|
/>
|
|
{errors.title && (
|
|
<div style={errorStyle}>
|
|
{/* Plain text — XSS guard (T-03-15) */}
|
|
{errors.title}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* All-day toggle */}
|
|
<div
|
|
style={{
|
|
...fieldStyle,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3)',
|
|
}}
|
|
>
|
|
<button
|
|
role="switch"
|
|
aria-checked={allDay}
|
|
onClick={handleAllDayToggle}
|
|
style={{
|
|
width: '44px',
|
|
height: '26px',
|
|
borderRadius: '13px',
|
|
background: allDay ? 'var(--color-text-primary)' : 'var(--color-border)',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
position: 'relative',
|
|
transition: 'background 0.15s',
|
|
flexShrink: 0,
|
|
minWidth: '44px',
|
|
minHeight: '26px',
|
|
padding: 0,
|
|
}}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '3px',
|
|
left: allDay ? '21px' : '3px',
|
|
width: '20px',
|
|
height: '20px',
|
|
borderRadius: '50%',
|
|
background: '#fff',
|
|
transition: 'left 0.15s',
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
}}
|
|
/>
|
|
</button>
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-label-size)',
|
|
color: 'var(--color-text-secondary)',
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={handleAllDayToggle}
|
|
>
|
|
All day
|
|
</span>
|
|
</div>
|
|
|
|
{/* Start date / time */}
|
|
<div style={{ ...fieldStyle, display: 'flex', gap: 'var(--space-3)' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label htmlFor="event-start-date" style={labelStyle}>
|
|
Start
|
|
</label>
|
|
<input
|
|
id="event-start-date"
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => {
|
|
const newStart = e.target.value;
|
|
// D-04: recompute end to preserve duration when start date changes
|
|
if (allDay) {
|
|
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate));
|
|
} else {
|
|
const { endDate: ed, endTime: et } = computeNewTimedEnd(
|
|
newStart,
|
|
startTime,
|
|
startDate,
|
|
startTime,
|
|
endDate,
|
|
endTime,
|
|
);
|
|
setEndDate(ed);
|
|
setEndTime(et);
|
|
}
|
|
setStartDate(newStart);
|
|
}}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
{!allDay && (
|
|
<div style={{ flex: '0 0 110px' }}>
|
|
<label htmlFor="event-start-time" style={labelStyle}>
|
|
|
|
</label>
|
|
<input
|
|
id="event-start-time"
|
|
type="time"
|
|
value={startTime}
|
|
onChange={(e) => {
|
|
const newTime = e.target.value;
|
|
// D-04: recompute end to preserve duration when start time changes (timed only)
|
|
const { endDate: ed, endTime: et } = computeNewTimedEnd(
|
|
startDate,
|
|
newTime,
|
|
startDate,
|
|
startTime,
|
|
endDate,
|
|
endTime,
|
|
);
|
|
setEndDate(ed);
|
|
setEndTime(et);
|
|
setStartTime(newTime);
|
|
}}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* End date / time */}
|
|
<div style={{ ...fieldStyle, display: 'flex', gap: 'var(--space-3)' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label htmlFor="event-end-date" style={labelStyle}>
|
|
End
|
|
</label>
|
|
<input
|
|
id="event-end-date"
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
style={{
|
|
...inputStyle,
|
|
...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}),
|
|
}}
|
|
/>
|
|
</div>
|
|
{!allDay && (
|
|
<div style={{ flex: '0 0 110px' }}>
|
|
<label htmlFor="event-end-time" style={labelStyle}>
|
|
|
|
</label>
|
|
<input
|
|
id="event-end-time"
|
|
type="time"
|
|
value={endTime}
|
|
onChange={(e) => setEndTime(e.target.value)}
|
|
style={{
|
|
...inputStyle,
|
|
...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{errors.endTime && (
|
|
<div style={{ ...errorStyle, marginTop: '-8px', marginBottom: 'var(--space-4)' }}>
|
|
{/* Plain text — XSS guard (T-03-15) */}
|
|
{errors.endTime}
|
|
</div>
|
|
)}
|
|
|
|
{/* Calendar picker (D-02: hidden when only 1 writable calendar) */}
|
|
{showPicker && (
|
|
<div style={fieldStyle}>
|
|
<label htmlFor="event-calendar" style={labelStyle}>
|
|
Calendar
|
|
</label>
|
|
<select
|
|
id="event-calendar"
|
|
value={calendarUrl}
|
|
onChange={(e) => setCalendarUrl(e.target.value)}
|
|
style={{
|
|
...inputStyle,
|
|
padding: '0 var(--space-3)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{writableCalendars.map((cal) => (
|
|
<option key={cal.url} value={cal.url}>
|
|
{/* Plain text — XSS guard (T-03-15) */}
|
|
{cal.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recurrence picker (D-11: whole-series only).
|
|
WR-01: disabled in edit mode — the occurrence contract does not expose the
|
|
event's recurrence, so the form cannot show/change it without risking a
|
|
silent reset. Editing recurrence is deferred until the contract exposes it;
|
|
the existing RRULE is preserved server-side on edit. */}
|
|
<div style={fieldStyle}>
|
|
<label htmlFor="event-recurrence" style={labelStyle}>
|
|
Repeat
|
|
</label>
|
|
<select
|
|
id="event-recurrence"
|
|
value={recurrence}
|
|
disabled={eventFormMode === 'edit'}
|
|
onChange={(e) => setRecurrence(e.target.value as RecurrencePreset)}
|
|
style={{
|
|
...inputStyle,
|
|
padding: '0 var(--space-3)',
|
|
cursor: eventFormMode === 'edit' ? 'not-allowed' : 'pointer',
|
|
opacity: eventFormMode === 'edit' ? 0.6 : 1,
|
|
}}
|
|
>
|
|
<option value="none">None</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly">Monthly</option>
|
|
<option value="yearly">Yearly</option>
|
|
</select>
|
|
{/* WR-01: surface the v1 constraint so a user editing a recurring event is not
|
|
silently surprised that the schedule is locked. Additive helper text only —
|
|
the existing RRULE is preserved server-side on edit. */}
|
|
{eventFormMode === 'edit' && (
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size)',
|
|
color: 'var(--color-text-secondary)',
|
|
marginTop: 'var(--space-1)',
|
|
}}
|
|
>
|
|
{/* Plain text — XSS guard (T-03-15) */}
|
|
Repeat can't be changed yet — edits keep the existing schedule.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phase 11: Reminder picker (allDay-aware swap, D-01/D-02/D-03/D-07/D-08).
|
|
NOT disabled in edit mode — reminders are editable (unlike Repeat/WR-01). */}
|
|
<div style={fieldStyle}>
|
|
<label htmlFor="event-reminder" style={labelStyle}>
|
|
Reminder
|
|
</label>
|
|
<select
|
|
id="event-reminder"
|
|
value={reminderValue}
|
|
onChange={(e) => setReminderValue(e.target.value)}
|
|
style={{
|
|
...inputStyle,
|
|
padding: '0 var(--space-3)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{allDay ? (
|
|
// D-02: all-day presets (day-granularity)
|
|
<>
|
|
<option value="__none__">None</option>
|
|
<option value="0">Same day (9 AM)</option>
|
|
<option value="1440">1 day before (9 AM)</option>
|
|
<option value="2880">2 days before (9 AM)</option>
|
|
<option value="10080">1 week before (9 AM)</option>
|
|
{/* D-07: synthetic option for off-list all-day value */}
|
|
{reminderValue !== '__none__' &&
|
|
reminderValue !== '__custom__' &&
|
|
!ALLDAY_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
|
|
Number.isFinite(parseInt(reminderValue, 10)) && (
|
|
<option value={reminderValue}>
|
|
{humanizeReminderLead(parseInt(reminderValue, 10))}
|
|
</option>
|
|
)}
|
|
{/* D-07: read-only Custom (kept) for absolute/multi alarm */}
|
|
{reminderValue === '__custom__' && (
|
|
<option value="__custom__" disabled>
|
|
Custom (kept)
|
|
</option>
|
|
)}
|
|
</>
|
|
) : (
|
|
// D-01: timed presets
|
|
<>
|
|
<option value="__none__">None</option>
|
|
<option value="5">5 minutes before</option>
|
|
<option value="10">10 minutes before</option>
|
|
<option value="15">15 minutes before</option>
|
|
<option value="30">30 minutes before</option>
|
|
<option value="60">1 hour before</option>
|
|
<option value="120">2 hours before</option>
|
|
<option value="1440">1 day before</option>
|
|
<option value="2880">2 days before</option>
|
|
{/* D-07: synthetic option for off-list timed value */}
|
|
{reminderValue !== '__none__' &&
|
|
reminderValue !== '__custom__' &&
|
|
!TIMED_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
|
|
Number.isFinite(parseInt(reminderValue, 10)) && (
|
|
<option value={reminderValue}>
|
|
{humanizeReminderLead(parseInt(reminderValue, 10))}
|
|
</option>
|
|
)}
|
|
{/* D-07: read-only Custom (kept) for absolute/multi alarm */}
|
|
{reminderValue === '__custom__' && (
|
|
<option value="__custom__" disabled>
|
|
Custom (kept)
|
|
</option>
|
|
)}
|
|
</>
|
|
)}
|
|
</select>
|
|
{/* Helper text: shown in edit mode when value is __custom__ or synthetic off-list (D-07) */}
|
|
{/* WR-03: gate on the ACTIVE preset set only — not both — so a timed event with a
|
|
value that happens to be in the allDay set still shows the off-list helper text */}
|
|
{eventFormMode === 'edit' &&
|
|
(reminderValue === '__custom__' ||
|
|
(reminderValue !== '__none__' &&
|
|
!(allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS).has(
|
|
parseInt(reminderValue, 10),
|
|
) &&
|
|
Number.isFinite(parseInt(reminderValue, 10)))) && (
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size)',
|
|
color: 'var(--color-text-secondary)',
|
|
marginTop: 'var(--space-1)',
|
|
}}
|
|
>
|
|
{/* Plain text — XSS guard (T-11-10 / T-03-15) */}
|
|
Custom reminder kept — select a preset to replace it.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */}
|
|
{eventFormMode !== 'edit' && recurrence !== 'none' && (
|
|
<div style={fieldStyle}>
|
|
<label htmlFor="recurrence-bound" style={labelStyle}>
|
|
Ends
|
|
</label>
|
|
<select
|
|
id="recurrence-bound"
|
|
value={recurrenceBound}
|
|
onChange={(e) => setRecurrenceBound(e.target.value as 'never' | 'until' | 'count')}
|
|
style={{
|
|
...inputStyle,
|
|
padding: '0 var(--space-3)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<option value="never">Never</option>
|
|
<option value="until">On date</option>
|
|
<option value="count">After N times</option>
|
|
</select>
|
|
|
|
{/* "On date" — date input */}
|
|
{recurrenceBound === 'until' && (
|
|
<div style={{ marginTop: 'var(--space-2)' }}>
|
|
<label htmlFor="recurrence-until" style={labelStyle}>
|
|
End date
|
|
</label>
|
|
<input
|
|
id="recurrence-until"
|
|
type="date"
|
|
value={recurrenceUntil}
|
|
onChange={(e) => setRecurrenceUntil(e.target.value)}
|
|
style={{
|
|
...inputStyle,
|
|
...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* "After N times" — number input */}
|
|
{recurrenceBound === 'count' && (
|
|
<div style={{ marginTop: 'var(--space-2)' }}>
|
|
<label htmlFor="recurrence-count" style={labelStyle}>
|
|
Occurrences
|
|
</label>
|
|
<input
|
|
id="recurrence-count"
|
|
type="number"
|
|
min={1}
|
|
placeholder="e.g. 10"
|
|
value={recurrenceCount}
|
|
onChange={(e) => {
|
|
// WR-03: parseInt + Number.isFinite guard. Number('') === 0 and
|
|
// Number('-'/'e') === NaN both previously slipped through; coerce any
|
|
// non-finite intermediate to 0 so the validate() guard catches it.
|
|
const n = parseInt(e.target.value, 10);
|
|
setRecurrenceCount(Number.isFinite(n) ? n : 0);
|
|
}}
|
|
style={{
|
|
...inputStyle,
|
|
...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Inline validation error (T-06-06-input) */}
|
|
{errors.recurrenceBound && (
|
|
<div style={errorStyle}>
|
|
{/* Plain text — XSS guard (T-03-15) */}
|
|
{errors.recurrenceBound}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Location */}
|
|
<div style={fieldStyle}>
|
|
<label htmlFor="event-location" style={labelStyle}>
|
|
Location
|
|
</label>
|
|
<input
|
|
id="event-location"
|
|
type="text"
|
|
placeholder="Add location"
|
|
value={location}
|
|
onChange={(e) => setLocation(e.target.value)}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div style={fieldStyle}>
|
|
<label htmlFor="event-description" style={labelStyle}>
|
|
Description
|
|
</label>
|
|
<textarea
|
|
id="event-description"
|
|
placeholder="Add description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
style={{
|
|
...inputStyle,
|
|
minHeight: '80px',
|
|
padding: 'var(--space-3)',
|
|
resize: 'vertical',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Footer: Cancel + Save */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
gap: 'var(--space-4)',
|
|
marginTop: 'var(--space-4)',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
minHeight: '44px',
|
|
padding: '0 var(--space-4)',
|
|
fontSize: 'var(--text-label-size)',
|
|
color: 'var(--color-text-secondary)',
|
|
borderRadius: 'var(--space-1)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={mutation.isPending}
|
|
style={{
|
|
background: 'var(--color-text-primary)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
cursor: mutation.isPending ? 'not-allowed' : 'pointer',
|
|
minHeight: '44px',
|
|
padding: '0 var(--space-6)',
|
|
fontSize: 'var(--text-label-size)',
|
|
fontWeight: 600,
|
|
borderRadius: 'var(--space-1)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-2)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
opacity: mutation.isPending ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{mutation.isPending && (
|
|
<Loader2
|
|
size={16}
|
|
aria-hidden="true"
|
|
style={{ animation: 'spin 1s linear infinite' }}
|
|
/>
|
|
)}
|
|
{/* Plain text — XSS guard (T-03-15) */}
|
|
{saveLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* D-08/D-09: Series-edit confirmation prompt — shown when editing a recurring occurrence */}
|
|
<SeriesEditPrompt
|
|
open={seriesEditPromptOpen}
|
|
onConfirm={() => {
|
|
setSeriesEditPromptOpen(false);
|
|
executeSubmit();
|
|
}}
|
|
onCancel={() => setSeriesEditPromptOpen(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|