style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -28,29 +28,29 @@
|
||||
* - 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 { 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'
|
||||
} 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'
|
||||
} from '../lib/eventDateTime.js';
|
||||
import { SeriesEditPrompt } from './SeriesEditPrompt.js';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap.js';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'
|
||||
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl';
|
||||
|
||||
// IN-03: todayIso is now imported from calendarStore (single source of truth).
|
||||
// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites.
|
||||
@@ -59,22 +59,22 @@ const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'
|
||||
|
||||
/** Determine if we're on phone breakpoint. */
|
||||
function isPhoneBreakpoint(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||
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)
|
||||
return localStorage.getItem(LAST_CALENDAR_KEY);
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist last-used calendar URL (D-01). */
|
||||
function writeLastCalendarUrl(url: string): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_CALENDAR_KEY, url)
|
||||
localStorage.setItem(LAST_CALENDAR_KEY, url);
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
@@ -90,15 +90,15 @@ function writeLastCalendarUrl(url: string): void {
|
||||
* 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}`
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,13 +113,13 @@ function exclusiveEndToInclusiveDate(dateStr: string): string {
|
||||
function parseDateTime(iso: string): { date: string; time: string; ok: boolean } {
|
||||
try {
|
||||
// Strip IANA bracket suffix e.g. '[America/Toronto]'
|
||||
const clean = iso.replace(/\[[^\]]*\]$/, '')
|
||||
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 }
|
||||
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
|
||||
@@ -127,23 +127,23 @@ function parseDateTime(iso: string): { date: string; time: string; ok: boolean }
|
||||
// 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')
|
||||
throw new Error('Malformed timed datetime');
|
||||
}
|
||||
const d = new Date(clean)
|
||||
if (isNaN(d.getTime())) throw new Error('Invalid date')
|
||||
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 }
|
||||
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 }
|
||||
return { date: todayIso(), time: '09:00', ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,37 +159,37 @@ function initFormDateTime(
|
||||
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 }
|
||||
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 }
|
||||
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)
|
||||
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
|
||||
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
|
||||
if (!data?.occurrences) continue;
|
||||
const found = data.occurrences.find((o) => o.uid === eventFormUid || o.id === eventFormUid);
|
||||
if (found) return found;
|
||||
}
|
||||
return null
|
||||
})()
|
||||
return null;
|
||||
})();
|
||||
|
||||
// ── Writable calendars (D-03) ───────────────────────────────────────────────
|
||||
|
||||
@@ -198,49 +198,49 @@ export function EventForm() {
|
||||
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')
|
||||
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
|
||||
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')
|
||||
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');
|
||||
// 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 [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 ?? '';
|
||||
}
|
||||
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)
|
||||
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
|
||||
}, [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
|
||||
@@ -249,22 +249,22 @@ export function EventForm() {
|
||||
// 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')
|
||||
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)
|
||||
: 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
|
||||
@@ -272,22 +272,26 @@ export function EventForm() {
|
||||
// (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')
|
||||
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 ?? '')
|
||||
setRecurrenceBound('never');
|
||||
setRecurrenceUntil('');
|
||||
setRecurrenceCount(1);
|
||||
setLocation(occurrence?.location ?? '');
|
||||
setDescription(occurrence?.description ?? '');
|
||||
}
|
||||
}, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Validation state ────────────────────────────────────────────────────────
|
||||
|
||||
const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({})
|
||||
const [errors, setErrors] = useState<{
|
||||
title?: string;
|
||||
endTime?: string;
|
||||
recurrenceBound?: string;
|
||||
}>({});
|
||||
// D-08/D-09: series-edit confirmation prompt state
|
||||
const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false)
|
||||
const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false);
|
||||
|
||||
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -298,24 +302,24 @@ export function EventForm() {
|
||||
: createEvent(payload),
|
||||
onSuccess: (data) => {
|
||||
// Wire sync-toast: set the returned UID so SyncStateToast starts polling (D-05/D-09)
|
||||
setLastSyncedUid(data.uid)
|
||||
setLastSyncedUid(data.uid);
|
||||
// Do NOT invalidate here — SyncStateToast does it on done/conflict (D-06/D-08)
|
||||
if (calendarUrl) writeLastCalendarUrl(calendarUrl)
|
||||
setEventForm(false)
|
||||
if (calendarUrl) writeLastCalendarUrl(calendarUrl);
|
||||
setEventForm(false);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleClose = () => setEventForm(false)
|
||||
const handleClose = () => setEventForm(false);
|
||||
|
||||
const handleAllDayToggle = () => {
|
||||
const next = !allDay
|
||||
setAllDay(next)
|
||||
const next = !allDay;
|
||||
setAllDay(next);
|
||||
if (!next) {
|
||||
// Turning off all-day: restore default times
|
||||
setStartTime('09:00')
|
||||
setEndTime('10:00')
|
||||
setStartTime('09:00');
|
||||
setEndTime('10:00');
|
||||
}
|
||||
if (next) {
|
||||
// WR-02: turning all-day ON discards the time inputs, so a midnight-spanning
|
||||
@@ -324,36 +328,36 @@ export function EventForm() {
|
||||
// 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))
|
||||
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 } = {}
|
||||
const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {};
|
||||
|
||||
if (!title.trim()) {
|
||||
newErrors.title = 'Title is required'
|
||||
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
|
||||
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`
|
||||
const startISO = `${startDate}T${startTime}:00`;
|
||||
const endISO = `${endDate}T${endTime}:00`;
|
||||
if (endISO <= startISO) {
|
||||
newErrors.endTime = 'End time must be after start'
|
||||
newErrors.endTime = 'End time must be after start';
|
||||
}
|
||||
} else {
|
||||
if (endDate < startDate) {
|
||||
newErrors.endTime = 'End time must be after start'
|
||||
newErrors.endTime = 'End time must be after start';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,27 +370,27 @@ export function EventForm() {
|
||||
// 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'
|
||||
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'
|
||||
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'
|
||||
newErrors.recurrenceBound = 'End date must be after the event starts';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the payload and fire the mutation. Called directly for non-recurring edits
|
||||
@@ -405,7 +409,7 @@ export function EventForm() {
|
||||
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
|
||||
@@ -413,7 +417,7 @@ export function EventForm() {
|
||||
// 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
|
||||
const isEdit = eventFormMode === 'edit' && !!eventFormUid;
|
||||
const payload: CreateEventPayload = {
|
||||
title: title.trim(),
|
||||
allDay,
|
||||
@@ -430,65 +434,65 @@ export function EventForm() {
|
||||
...(location.trim() ? { location: location.trim() } : {}),
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
|
||||
}
|
||||
};
|
||||
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
mutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return
|
||||
if (!validate()) return;
|
||||
|
||||
const isEdit = eventFormMode === 'edit' && !!eventFormUid
|
||||
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
|
||||
setSeriesEditPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
executeSubmit()
|
||||
}
|
||||
executeSubmit();
|
||||
};
|
||||
|
||||
// ── Keyboard: Escape to close ───────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventFormOpen) return
|
||||
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
|
||||
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)
|
||||
const handleDialogKeyDown = useFocusTrap(dialogRef);
|
||||
|
||||
// ── Focus: move to title input on open ─────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (eventFormOpen && titleRef.current) {
|
||||
titleRef.current.focus()
|
||||
titleRef.current.focus();
|
||||
}
|
||||
}, [eventFormOpen])
|
||||
}, [eventFormOpen]);
|
||||
|
||||
// ── Early return ────────────────────────────────────────────────────────────
|
||||
|
||||
if (!eventFormOpen) return null
|
||||
if (!eventFormOpen) return null;
|
||||
|
||||
// ── Responsive styles ───────────────────────────────────────────────────────
|
||||
|
||||
const isPhone = isPhoneBreakpoint()
|
||||
const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event'
|
||||
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 isEditRecurring = eventFormMode === 'edit' && occurrence?.hasRrule === true;
|
||||
const saveLabel = mutation.isPending
|
||||
? 'Saving…'
|
||||
: isEditRecurring
|
||||
? 'Update series'
|
||||
: eventFormMode === 'edit'
|
||||
? 'Save Changes'
|
||||
: 'Create Event'
|
||||
: 'Create Event';
|
||||
|
||||
const dialogStyle: React.CSSProperties = isPhone
|
||||
? {
|
||||
@@ -520,7 +524,7 @@ export function EventForm() {
|
||||
overflowY: 'auto',
|
||||
zIndex: 200,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
};
|
||||
|
||||
// ── Shared input style ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -537,7 +541,7 @@ export function EventForm() {
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
}
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
@@ -545,19 +549,19 @@ export function EventForm() {
|
||||
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
|
||||
const showPicker = writableCalendars.length > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -717,18 +721,23 @@ export function EventForm() {
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
const newStart = e.target.value
|
||||
const newStart = e.target.value;
|
||||
// D-04: recompute end to preserve duration when start date changes
|
||||
if (allDay) {
|
||||
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate))
|
||||
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate));
|
||||
} else {
|
||||
const { endDate: ed, endTime: et } = computeNewTimedEnd(
|
||||
newStart, startTime, startDate, startTime, endDate, endTime,
|
||||
)
|
||||
setEndDate(ed)
|
||||
setEndTime(et)
|
||||
newStart,
|
||||
startTime,
|
||||
startDate,
|
||||
startTime,
|
||||
endDate,
|
||||
endTime,
|
||||
);
|
||||
setEndDate(ed);
|
||||
setEndTime(et);
|
||||
}
|
||||
setStartDate(newStart)
|
||||
setStartDate(newStart);
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -743,14 +752,19 @@ export function EventForm() {
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
const newTime = e.target.value
|
||||
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)
|
||||
startDate,
|
||||
newTime,
|
||||
startDate,
|
||||
startTime,
|
||||
endDate,
|
||||
endTime,
|
||||
);
|
||||
setEndDate(ed);
|
||||
setEndTime(et);
|
||||
setStartTime(newTime);
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -926,8 +940,8 @@ export function EventForm() {
|
||||
// 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)
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setRecurrenceCount(Number.isFinite(n) ? n : 0);
|
||||
}}
|
||||
style={{
|
||||
...inputStyle,
|
||||
@@ -1031,7 +1045,11 @@ export function EventForm() {
|
||||
}}
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} aria-hidden="true" style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<Loader2
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
/>
|
||||
)}
|
||||
{/* Plain text — XSS guard (T-03-15) */}
|
||||
{saveLabel}
|
||||
@@ -1043,11 +1061,11 @@ export function EventForm() {
|
||||
<SeriesEditPrompt
|
||||
open={seriesEditPromptOpen}
|
||||
onConfirm={() => {
|
||||
setSeriesEditPromptOpen(false)
|
||||
executeSubmit()
|
||||
setSeriesEditPromptOpen(false);
|
||||
executeSubmit();
|
||||
}}
|
||||
onCancel={() => setSeriesEditPromptOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user