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:
@@ -40,7 +40,11 @@ import {
|
||||
type RecurrencePreset,
|
||||
} 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -205,6 +209,10 @@ export function EventForm() {
|
||||
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 [calendarUrl, setCalendarUrl] = useState<string>(() => {
|
||||
@@ -256,6 +264,10 @@ export function EventForm() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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 ?? '')
|
||||
}
|
||||
@@ -263,7 +275,7 @@ export function EventForm() {
|
||||
|
||||
// ── Validation state ────────────────────────────────────────────────────────
|
||||
|
||||
const [errors, setErrors] = useState<{ title?: string; endTime?: string }>({})
|
||||
const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({})
|
||||
|
||||
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -306,7 +318,7 @@ export function EventForm() {
|
||||
}
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: { title?: string; endTime?: string } = {}
|
||||
const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {}
|
||||
|
||||
if (!title.trim()) {
|
||||
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)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
@@ -365,6 +388,13 @@ export function EventForm() {
|
||||
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() } : {}),
|
||||
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
|
||||
@@ -668,7 +698,20 @@ export function EventForm() {
|
||||
id="event-start-date"
|
||||
type="date"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -681,7 +724,16 @@ export function EventForm() {
|
||||
id="event-start-time"
|
||||
type="time"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -800,6 +852,77 @@ export function EventForm() {
|
||||
)}
|
||||
</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 */}
|
||||
<div style={fieldStyle}>
|
||||
<label htmlFor="event-location" style={labelStyle}>
|
||||
|
||||
Reference in New Issue
Block a user