Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
Showing only changes of commit cbf5f98eb9 - Show all commits
+128 -5
View File
@@ -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}>