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,
|
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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user