feat(06-06): wire end-tracking and recurrence-bound control into EventForm

- Import computeNewTimedEnd/computeNewAllDayEnd from eventDateTime.ts (D-04)
- Start date onChange: calls computeNewAllDayEnd (all-day) or computeNewTimedEnd (timed)
- Start time onChange: calls computeNewTimedEnd preserving duration (timed only)
- Add recurrenceBound/recurrenceUntil/recurrenceCount state (D-06)
- Reset useEffect extended to reset bound state on form open
- Add 'Ends' control (Never/On date/After N times) shown when recurrence != none
- Inline validation: count < 1 and until < start
- Payload conditionally includes recurrenceUntil/recurrenceCount (create mode only)
- Error state type extended for recurrenceBound validation
This commit is contained in:
Lucas Berger
2026-06-10 11:38:24 -04:00
parent 9aa15c484b
commit cbf5f98eb9
+128 -5
View File
@@ -40,7 +40,11 @@ import {
type RecurrencePreset, type RecurrencePreset,
} from '../api/client.js' } from '../api/client.js'
import type { CalendarOccurrence } from '../api/client.js' import type { CalendarOccurrence } from '../api/client.js'
import { serializeEventDateTime } from '../lib/eventDateTime.js' import {
serializeEventDateTime,
computeNewTimedEnd,
computeNewAllDayEnd,
} from '../lib/eventDateTime.js'
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@@ -205,6 +209,10 @@ export function EventForm() {
const [endDate, setEndDate] = useState(initEndDate) const [endDate, setEndDate] = useState(initEndDate)
const [endTime, setEndTime] = useState(initEnd.time) const [endTime, setEndTime] = useState(initEnd.time)
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none') 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 [location, setLocation] = useState(occurrence?.location ?? '')
const [description, setDescription] = useState(occurrence?.description ?? '') const [description, setDescription] = useState(occurrence?.description ?? '')
const [calendarUrl, setCalendarUrl] = useState<string>(() => { const [calendarUrl, setCalendarUrl] = useState<string>(() => {
@@ -256,6 +264,10 @@ export function EventForm() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined
setRecurrence(derivedRecurrence ?? 'none') setRecurrence(derivedRecurrence ?? 'none')
// D-06: reset bound state to defaults on form open/occurrence change
setRecurrenceBound('never')
setRecurrenceUntil('')
setRecurrenceCount(1)
setLocation(occurrence?.location ?? '') setLocation(occurrence?.location ?? '')
setDescription(occurrence?.description ?? '') setDescription(occurrence?.description ?? '')
} }
@@ -263,7 +275,7 @@ export function EventForm() {
// ── Validation state ──────────────────────────────────────────────────────── // ── Validation state ────────────────────────────────────────────────────────
const [errors, setErrors] = useState<{ title?: string; endTime?: string }>({}) const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({})
// ── Mutations ─────────────────────────────────────────────────────────────── // ── Mutations ───────────────────────────────────────────────────────────────
@@ -306,7 +318,7 @@ export function EventForm() {
} }
const validate = (): boolean => { const validate = (): boolean => {
const newErrors: { title?: string; endTime?: string } = {} const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {}
if (!title.trim()) { if (!title.trim()) {
newErrors.title = 'Title is required' newErrors.title = 'Title is required'
@@ -333,6 +345,17 @@ export function EventForm() {
} }
} }
// D-06: validate recurrence bound inputs (T-06-06-input: client-side UX gate)
if (recurrence !== 'none') {
if (recurrenceBound === 'count' && recurrenceCount < 1) {
newErrors.recurrenceBound = 'Must be at least 1 occurrence'
} else if (recurrenceBound === 'until' && recurrenceUntil) {
if (recurrenceUntil < startDate) {
newErrors.recurrenceBound = 'End date must be after the event starts'
}
}
}
setErrors(newErrors) setErrors(newErrors)
return Object.keys(newErrors).length === 0 return Object.keys(newErrors).length === 0
} }
@@ -365,6 +388,13 @@ export function EventForm() {
start: serializedStart, start: serializedStart,
end: serializedEnd, end: serializedEnd,
...(isEdit ? {} : { recurrence }), ...(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() } : {}), ...(location.trim() ? { location: location.trim() } : {}),
...(description.trim() ? { description: description.trim() } : {}), ...(description.trim() ? { description: description.trim() } : {}),
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}), ...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
@@ -668,7 +698,20 @@ export function EventForm() {
id="event-start-date" id="event-start-date"
type="date" type="date"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} 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} style={inputStyle}
/> />
</div> </div>
@@ -681,7 +724,16 @@ export function EventForm() {
id="event-start-time" id="event-start-time"
type="time" type="time"
value={startTime} value={startTime}
onChange={(e) => setStartTime(e.target.value)} 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} style={inputStyle}
/> />
</div> </div>
@@ -800,6 +852,77 @@ export function EventForm() {
)} )}
</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) => setRecurrenceCount(Number(e.target.value))}
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 */} {/* Location */}
<div style={fieldStyle}> <div style={fieldStyle}>
<label htmlFor="event-location" style={labelStyle}> <label htmlFor="event-location" style={labelStyle}>