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
2 changed files with 135 additions and 13 deletions
Showing only changes of commit eed178fb39 - Show all commits
@@ -262,6 +262,36 @@ describe('EventForm', () => {
expect(timeInputs.length).toBeGreaterThan(0)
})
// WR-02 (iteration 2): toggling all-day ON deterministically clamps endDate to
// max(startDate, endDate). When the end day is BEHIND the start day, it snaps forward
// to a single-day event rather than validating as an inconsistent span, and any stale
// end-time error from the timed view is cleared.
it('toggling All-day ON clamps an end date that is behind the start date up to the start date', async () => {
renderForm()
fireEvent.change(screen.getByPlaceholderText('Event title'), {
target: { value: 'Span Title' },
})
const dateInputs = document.querySelectorAll('input[type="date"]')
expect(dateInputs.length).toBeGreaterThanOrEqual(2)
// Start 2026-06-10, end 2026-06-09 (end behind start) — invalid timed span
fireEvent.change(dateInputs[0], { target: { value: '2026-06-10' } })
fireEvent.change(dateInputs[1], { target: { value: '2026-06-09' } })
// Toggle all-day ON: endDate must clamp up to the start date (single-day event)
const allDaySwitch = screen.getByRole('switch')
fireEvent.click(allDaySwitch)
const dateInputsAfter = document.querySelectorAll('input[type="date"]')
expect((dateInputsAfter[1] as HTMLInputElement).value).toBe('2026-06-10')
// The clamped all-day event validates cleanly (no end-time error surfaced)
const saveButton = screen.getByText('Create Event')
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.queryByText('End time must be after start')).toBeNull()
})
})
// ── Calendar picker D-02 ───────────────────────────────────────────────────
it('calendar picker is absent when fetchWritableCalendars returns 1 calendar (D-02)', () => {
@@ -368,6 +398,39 @@ describe('EventForm', () => {
})
})
// WR-01 (iteration 2): edit mode shows explanatory helper text near the disabled
// recurrence select so the locked schedule is not a silent surprise.
it('WR-01: edit mode surfaces helper text that repeat cannot be changed', () => {
renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: EDIT_OCCURRENCE })
expect(screen.getByText(/Repeat can't be changed yet/i)).toBeDefined()
})
it('WR-01: create mode does NOT show the repeat helper text', () => {
renderForm({ mode: 'create' })
expect(screen.queryByText(/Repeat can't be changed yet/i)).toBeNull()
})
// IN-02 (iteration 2): in edit mode an unparseable cached start/end must leave the
// field blank and block submit, rather than silently rewriting the event to today/09:00.
it('IN-02: edit mode with an unparseable start leaves the date blank and blocks submit', async () => {
const corruptOccurrence: CalendarOccurrence = {
...EDIT_OCCURRENCE,
start: 'not-a-real-date',
}
renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: corruptOccurrence })
// The start date input must be blank (not today's date)
const dateInputs = document.querySelectorAll('input[type="date"]')
expect((dateInputs[0] as HTMLInputElement).value).toBe('')
// Submit must be blocked with a guidance message; updateEvent must NOT fire.
fireEvent.click(screen.getByText('Save Changes'))
await waitFor(() => {
expect(screen.getByText(/Couldn't read this event's date/i)).toBeDefined()
})
expect(mockUpdateEvent).not.toHaveBeenCalled()
})
// ── Close behaviors ────────────────────────────────────────────────────────
it('pressing Escape closes the form', () => {
+72 -13
View File
@@ -104,7 +104,7 @@ function exclusiveEndToInclusiveDate(dateStr: string): string {
* Both date and time use getFullYear/getMonth/getDate/getHours/getMinutes so
* the pair describes the same wall-clock consistently in the viewer's zone.
*/
function parseDateTime(iso: string): { date: string; time: string } {
function parseDateTime(iso: string): { date: string; time: string; ok: boolean } {
try {
// Strip IANA bracket suffix e.g. '[America/Toronto]'
const clean = iso.replace(/\[[^\]]*\]$/, '')
@@ -113,7 +113,7 @@ function parseDateTime(iso: string): { date: string; time: string } {
// 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' }
return { date: clean, time: '09:00', ok: true }
}
const d = new Date(clean)
if (isNaN(d.getTime())) throw new Error('Invalid date')
@@ -124,12 +124,35 @@ function parseDateTime(iso: string): { date: string; time: string } {
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}` }
return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}`, ok: true }
} catch {
return { date: todayIso(), time: '09:00' }
// 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 }
}
}
/**
* IN-02: resolve the form's initial date/time for a pre-filled occurrence value.
* In CREATE mode (or when there is no occurrence) a parse failure falls back to the
* benign today/09:00 default. In EDIT mode a parse failure leaves the field BLANK so
* the user sees the value did not load and submit is blocked (validate() treats a blank
* start/end as invalid), rather than silently substituting today/09:00 and saving it.
*/
function initFormDateTime(
iso: string | undefined,
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 }
// Parse failed
if (isEdit) return { date: '', time: '' }
return { date: todayIso(), time: fallbackTime }
}
// ── Component ─────────────────────────────────────────────────────────────────
export function EventForm() {
@@ -165,13 +188,15 @@ export function EventForm() {
// ── Form state ──────────────────────────────────────────────────────────────
const initStart = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' }
const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '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 ? 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)
@@ -206,12 +231,16 @@ export function EventForm() {
// has hydrated the occurrence for the requested UID.
useEffect(() => {
if (eventFormOpen) {
const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' }
const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '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 ? exclusiveEndToInclusiveDate(endParsed.date) : endParsed.date
occurrence?.allDay && endParsed.date
? exclusiveEndToInclusiveDate(endParsed.date)
: endParsed.date
setTitle(occurrence?.title ?? '')
setAllDay(occurrence?.allDay ?? false)
setStartDate(startParsed.date)
@@ -264,9 +293,15 @@ export function EventForm() {
setStartTime('09:00')
setEndTime('10:00')
}
if (next && endDate < startDate) {
// All-day ON: advance end date to match start date if it's behind
setEndDate(startDate)
if (next) {
// WR-02: turning all-day ON discards the time inputs, so a midnight-spanning
// timed event (start 06-10 23:00, end 06-11 01:00) would otherwise leave endDate
// at 06-11 — a 2-day all-day span the user did not intend. Clamp endDate to
// 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))
}
}
@@ -277,6 +312,15 @@ export function EventForm() {
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
}
if (!allDay) {
const startISO = `${startDate}T${startTime}:00`
const endISO = `${endDate}T${endTime}:00`
@@ -739,6 +783,21 @@ export function EventForm() {
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
{/* WR-01: surface the v1 constraint so a user editing a recurring event is not
silently surprised that the schedule is locked. Additive helper text only —
the existing RRULE is preserved server-side on edit. */}
{eventFormMode === 'edit' && (
<div
style={{
fontSize: 'var(--text-label-size)',
color: 'var(--color-text-secondary)',
marginTop: 'var(--space-1)',
}}
>
{/* Plain text — XSS guard (T-03-15) */}
Repeat can&apos;t be changed yet edits keep the existing schedule.
</div>
)}
</div>
{/* Location */}