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
4 changed files with 80 additions and 17 deletions
Showing only changes of commit 02aa407764 - Show all commits
+33 -13
View File
@@ -31,7 +31,7 @@ import { createFastmailClient } from './client.js'
import { decryptPassword } from './crypto.js'
import { syncCalendar } from './sync.js'
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'
import { buildVeventString, RRULE_PRESETS } from './vevent.js'
import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'
import type { FastmailClient } from './client.js'
// ── Constants (D-07) ────────────────────────────────────────────────────────
@@ -183,16 +183,19 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
} catch {
return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' }
}
const { icsString } = buildVeventString({
uid: row.uid,
summary: fields.title as string,
allDay: fields.allDay as boolean,
dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string),
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
location: fields.location as string | undefined,
description: fields.description as string | undefined,
rruleString: fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] : undefined,
})
// WR-01: recurrence preservation. The PWA omits `recurrence` from an edit payload
// (it cannot read the existing RRULE — not in the occurrence contract, D-03), so on
// update we must NOT rebuild the VEVENT with no RRULE — that would silently convert a
// recurring series into a single event. When the payload carries no explicit
// recurrence, fall back to the RRULE already stored in calendarEvents.rawVevent.
// An explicit recurrence value (including 'none') still overrides — that is a
// deliberate user change. Read rawVevent in the same scoped query as the fresh etag.
let preservedRrule: string | undefined
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
// WR-02: re-read the freshest etag from calendarEvents just before PUT.
// Rapid successive edits to the same uid enqueue multiple update rows, each
// carrying the etag at enqueue time. If a prior edit succeeded and triggered
@@ -210,7 +213,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// calendarUrl so the freshest etag belongs to the writing member.
let etagForPut: string | null = row.etag ?? null
const freshEtagRows = (await db
.select({ etag: calendarEvents.etag })
.select({ etag: calendarEvents.etag, rawVevent: calendarEvents.rawVevent })
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(
@@ -220,11 +223,28 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
eq(calendars.url, row.calendarUrl),
),
)
.limit(1)) as Array<{ etag: string | null }>
.limit(1)) as Array<{ etag: string | null; rawVevent: string | null }>
if (freshEtagRows.length > 0 && freshEtagRows[0].etag != null) {
etagForPut = freshEtagRows[0].etag
}
// WR-01: when the edit payload carries no explicit recurrence, preserve the RRULE
// already on the stored event so an edit does not strip a recurring series.
if (!hasExplicitRecurrence && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) {
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent)
}
const { icsString } = buildVeventString({
uid: row.uid,
summary: fields.title as string,
allDay: fields.allDay as boolean,
dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string),
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
location: fields.location as string | undefined,
description: fields.description as string | undefined,
rruleString: hasExplicitRecurrence ? rruleFromPayload : (preservedRrule ?? rruleFromPayload),
})
response = await updateCalendarEvent(
client,
row.calendarObjectUrl,
+23
View File
@@ -43,6 +43,29 @@ export const RRULE_PRESETS: Record<string, string> = {
yearly: 'FREQ=YEARLY',
}
/**
* WR-01: Extract the existing RRULE string from a stored VCALENDAR/VEVENT, so the
* outbox worker can preserve recurrence on an edit whose payload omits it (the PWA
* occurrence contract does not expose recurrence, D-03). Returns the RRULE value as a
* 'FREQ=…' string (e.g. 'FREQ=WEEKLY'), or undefined when the event has no RRULE or
* the input cannot be parsed.
*/
export function extractRruleString(rawVevent: string): string | undefined {
let parsed: ReturnType<typeof ICAL.parse>
try {
parsed = ICAL.parse(rawVevent)
} catch {
return undefined
}
const comp = new ICAL.Component(parsed)
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) return undefined
const rrule = vevent.getFirstPropertyValue('rrule')
if (!rrule) return undefined
// ICAL.Recur#toString() yields the RECUR value, e.g. 'FREQ=WEEKLY'.
return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString()
}
/**
* Builds a VCALENDAR/VEVENT iCalendar string from form parameters.
*
+4 -1
View File
@@ -138,7 +138,10 @@ export interface CreateEventPayload {
allDay: boolean
start: string // 'YYYY-MM-DD' for allDay; ISO 8601 for timed
end: string // same format as start
recurrence: RecurrencePreset
// WR-01: optional. CREATE always sends it; EDIT omits it so the API/worker preserve
// the event's existing RRULE (the occurrence contract does not expose recurrence, so
// the form cannot echo it back without silently resetting it to 'none').
recurrence?: RecurrencePreset
location?: string
description?: string
calendarUrl?: string // omit to use the member's default writable calendar (D-01)
+20 -3
View File
@@ -305,12 +305,19 @@ export function EventForm() {
endTime,
)
// WR-01: on EDIT, omit `recurrence` from the payload. The occurrence/expand contract
// does not expose the event's existing recurrence (D-03), so the form cannot know it
// and would otherwise send 'none' — silently stripping the RRULE and converting a
// recurring series into a single event. Omitting the field signals "unchanged"; the
// outbox worker then preserves the stored RRULE (see outboxWorker.ts WR-01). On
// CREATE the user explicitly chose a recurrence, so it is always sent.
const isEdit = eventFormMode === 'edit' && !!eventFormUid
const payload: CreateEventPayload = {
title: title.trim(),
allDay,
start: serializedStart,
end: serializedEnd,
recurrence,
...(isEdit ? {} : { recurrence }),
...(location.trim() ? { location: location.trim() } : {}),
...(description.trim() ? { description: description.trim() } : {}),
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
@@ -702,7 +709,11 @@ export function EventForm() {
</div>
)}
{/* Recurrence picker (D-11: whole-series only) */}
{/* Recurrence picker (D-11: whole-series only).
WR-01: disabled in edit mode — the occurrence contract does not expose the
event's recurrence, so the form cannot show/change it without risking a
silent reset. Editing recurrence is deferred until the contract exposes it;
the existing RRULE is preserved server-side on edit. */}
<div style={fieldStyle}>
<label htmlFor="event-recurrence" style={labelStyle}>
Repeat
@@ -710,8 +721,14 @@ export function EventForm() {
<select
id="event-recurrence"
value={recurrence}
disabled={eventFormMode === 'edit'}
onChange={(e) => setRecurrence(e.target.value as RecurrencePreset)}
style={{ ...inputStyle, padding: '0 var(--space-3)', cursor: 'pointer' }}
style={{
...inputStyle,
padding: '0 var(--space-3)',
cursor: eventFormMode === 'edit' ? 'not-allowed' : 'pointer',
opacity: eventFormMode === 'edit' ? 0.6 : 1,
}}
>
<option value="none">None</option>
<option value="daily">Daily</option>