From 6f298c36fe567392c123a6f9c5b2a39d863e73f2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 18:18:18 +0200 Subject: [PATCH] feat(scheduling): prefill participant availability --- .../test-scheduling-page-structure.mjs | 3 ++ webui/src/api/scheduling.ts | 21 +++++++++ .../features/scheduling/SchedulingPage.tsx | 47 +++++++++++++++++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/webui/scripts/test-scheduling-page-structure.mjs b/webui/scripts/test-scheduling-page-structure.mjs index 07e2b6d..3f44d88 100644 --- a/webui/scripts/test-scheduling-page-structure.mjs +++ b/webui/scripts/test-scheduling-page-structure.mjs @@ -52,10 +52,13 @@ assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/); assert.doesNotMatch(page, /header: I18N\.required|\{selected\.calendar_id\}<\/p>/); assert.match(page, /className="scheduling-selected-calendar"/); assert.match(page, /showPlanningCalendarActions/); assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/); assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/); +assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/); console.log("Scheduling page structure satisfies the focused list/create/respond contract."); diff --git a/webui/src/api/scheduling.ts b/webui/src/api/scheduling.ts index 2939731..fbe90c0 100644 --- a/webui/src/api/scheduling.ts +++ b/webui/src/api/scheduling.ts @@ -124,6 +124,17 @@ export type SchedulingAvailabilityPayload = { }>; }; +export type SchedulingAvailabilityResponse = { + request_id: string; + participant_id: string; + has_response: boolean; + submitted_at?: string | null; + answers: Array<{ + slot_id: string; + value: SchedulingAvailabilityValue; + }>; +}; + export type SchedulingCalendarActionResponse = { request: SchedulingRequest; created_event_ids: string[]; @@ -218,6 +229,16 @@ export function submitSchedulingAvailability( ); } +export function getSchedulingAvailabilityResponse( + settings: ApiSettings, + requestId: string +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/requests/${requestId}/responses/me` + ); +} + export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise { return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({})); } diff --git a/webui/src/features/scheduling/SchedulingPage.tsx b/webui/src/features/scheduling/SchedulingPage.tsx index 9fd175d..6620667 100644 --- a/webui/src/features/scheduling/SchedulingPage.tsx +++ b/webui/src/features/scheduling/SchedulingPage.tsx @@ -36,6 +36,7 @@ import { createSchedulingRequest, decideSchedulingRequest, evaluateSchedulingFreeBusy, + getSchedulingAvailabilityResponse, listSchedulingNotifications, listSchedulingRequests, openSchedulingRequest, @@ -176,6 +177,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin const [slots, setSlots] = useState(() => initialSlots(translateText)); const [participants, setParticipants] = useState(() => [emptyParticipant()]); const [availability, setAvailability] = useState>({}); + const [availabilityLoading, setAvailabilityLoading] = useState(false); const canWrite = hasScope(auth, "scheduling:schedule:write"); const canRespond = hasScope(auth, "scheduling:availability:write"); @@ -240,6 +242,39 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin void loadDetails(selected.id); }, [creating, selected?.id]); + useEffect(() => { + if (!selected?.id || !selectedParticipant || !canRespond || creating || selected.status !== "collecting") { + setAvailabilityLoading(false); + return undefined; + } + let cancelled = false; + setAvailabilityLoading(true); + void getSchedulingAvailabilityResponse(settings, selected.id) + .then((response) => { + if (cancelled) return; + setAvailability(Object.fromEntries(response.answers.map((answer) => [answer.slot_id, answer.value]))); + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err, translateText(I18N.requestFailed))); + }) + .finally(() => { + if (!cancelled) setAvailabilityLoading(false); + }); + return () => { + cancelled = true; + }; + }, [ + canRespond, + creating, + selected?.id, + selected?.status, + selectedParticipant?.id, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey, + translateText + ]); + useEffect(() => { if (!creating || !draftDirty) return undefined; const preventUnsavedExit = (event: BeforeUnloadEvent) => event.preventDefault(); @@ -366,7 +401,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin async function sendAvailability(event: FormEvent) { event.preventDefault(); - if (!selected || !selectedParticipant || !canRespond) return; + if (!selected || !selectedParticipant || !canRespond || availabilityLoading) return; const answers = selected.slots .map((slot) => ({ slot_id: slot.id, value: availability[slot.id] })) .filter((answer): answer is { slot_id: string; value: SchedulingAvailabilityValue } => Boolean(answer.value)); @@ -578,12 +613,16 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin variant="primary" type="submit" form="scheduling-response-form" - disabled={saving || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}> + disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}>