From fc418e63ac6896f9b88344285ffb759fad3a0bf8 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 17:34:30 +0200 Subject: [PATCH] feat(scheduling): prioritize request worklists --- .../scheduling/schedulingViewModel.ts | 146 ++++++++++++++++++ webui/tests/scheduling-view-model.test.ts | 111 +++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 webui/src/features/scheduling/schedulingViewModel.ts create mode 100644 webui/tests/scheduling-view-model.test.ts diff --git a/webui/src/features/scheduling/schedulingViewModel.ts b/webui/src/features/scheduling/schedulingViewModel.ts new file mode 100644 index 0000000..39c81ab --- /dev/null +++ b/webui/src/features/scheduling/schedulingViewModel.ts @@ -0,0 +1,146 @@ +import type { SchedulingParticipant, SchedulingRequest } from "../../api/scheduling"; + +export type SchedulingActor = { + accountId?: string | null; + userId?: string | null; + membershipId?: string | null; + identityId?: string | null; + email?: string | null; +}; + +export type SchedulingRequestGroups = { + owned: SchedulingRequest[]; + invited: SchedulingRequest[]; + other: SchedulingRequest[]; +}; + +export type SchedulingSortPhase = + | "unanswered" + | "answered" + | "closed" + | "determined" + | "past"; + +export function schedulingActorIds(actor: SchedulingActor): string[] { + return Array.from(new Set([ + actor.accountId, + actor.userId, + actor.membershipId, + actor.identityId, + actor.email + ].filter((value): value is string => Boolean(value)))); +} + +export function schedulingParticipantForActor( + request: SchedulingRequest, + actor: SchedulingActor +): SchedulingParticipant | null { + const ids = new Set(schedulingActorIds(actor)); + return request.participants.find((participant) => + Boolean( + (participant.respondent_id && ids.has(participant.respondent_id)) || + (participant.email && ids.has(participant.email)) + ) + ) ?? null; +} + +export function schedulingRequestIsOwned( + request: SchedulingRequest, + actor: SchedulingActor +): boolean { + return Boolean( + request.organizer_user_id && + schedulingActorIds(actor).includes(request.organizer_user_id) + ); +} + +export function schedulingSortPhase( + request: SchedulingRequest, + actor: SchedulingActor, + now = new Date() +): SchedulingSortPhase { + if (schedulingRequestIsPast(request, now)) return "past"; + if (["decided", "handed_off"].includes(request.status)) return "determined"; + if (["closed", "cancelled", "archived"].includes(request.status)) return "closed"; + if (schedulingRequestIsOwned(request, actor)) return "unanswered"; + const participant = schedulingParticipantForActor(request, actor); + return participant && ["responded", "declined"].includes(participant.status) + ? "answered" + : "unanswered"; +} + +export function compareSchedulingRequests( + left: SchedulingRequest, + right: SchedulingRequest, + actor: SchedulingActor, + now = new Date() +): number { + const leftPhase = schedulingSortPhase(left, actor, now); + const rightPhase = schedulingSortPhase(right, actor, now); + const phaseDifference = SORT_PHASE_ORDER[leftPhase] - SORT_PHASE_ORDER[rightPhase]; + if (phaseDifference !== 0) return phaseDifference; + const leftDate = schedulingRelevantTimestamp(left, now); + const rightDate = schedulingRelevantTimestamp(right, now); + const dateDifference = leftPhase === "past" + ? rightDate - leftDate + : leftDate - rightDate; + if (dateDifference !== 0) return dateDifference; + const titleDifference = left.title.localeCompare(right.title); + return titleDifference || left.id.localeCompare(right.id); +} + +export function groupSchedulingRequests( + requests: SchedulingRequest[], + actor: SchedulingActor, + now = new Date() +): SchedulingRequestGroups { + const groups: SchedulingRequestGroups = { owned: [], invited: [], other: [] }; + for (const request of requests) { + if (schedulingRequestIsOwned(request, actor)) { + groups.owned.push(request); + } else if (schedulingParticipantForActor(request, actor)) { + groups.invited.push(request); + } else { + groups.other.push(request); + } + } + for (const group of Object.values(groups)) { + group.sort((left, right) => compareSchedulingRequests(left, right, actor, now)); + } + return groups; +} + +export function schedulingRelevantTimestamp( + request: SchedulingRequest, + now = new Date() +): number { + const nowValue = now.getTime(); + const futureStarts = request.slots + .map((slot) => Date.parse(slot.start_at)) + .filter((value) => Number.isFinite(value) && value >= nowValue) + .sort((left, right) => left - right); + if (futureStarts[0] !== undefined) return futureStarts[0]; + const slotEnds = request.slots + .map((slot) => Date.parse(slot.end_at)) + .filter(Number.isFinite); + if (slotEnds.length) return Math.max(...slotEnds); + const fallback = Date.parse(request.deadline_at || request.updated_at || request.created_at); + return Number.isFinite(fallback) ? fallback : Number.MAX_SAFE_INTEGER; +} + +export function schedulingRequestIsPast( + request: SchedulingRequest, + now = new Date() +): boolean { + if (!request.slots.length) return false; + const slotEnds = request.slots.map((slot) => Date.parse(slot.end_at)); + return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime()); +} + +const SORT_PHASE_ORDER: Record = { + unanswered: 0, + answered: 1, + closed: 2, + determined: 3, + past: 4 +}; diff --git a/webui/tests/scheduling-view-model.test.ts b/webui/tests/scheduling-view-model.test.ts new file mode 100644 index 0000000..4b923d3 --- /dev/null +++ b/webui/tests/scheduling-view-model.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { SchedulingRequest } from "../src/api/scheduling.ts"; +import { + groupSchedulingRequests, + schedulingSortPhase, + type SchedulingActor +} from "../src/features/scheduling/schedulingViewModel.ts"; + +const now = new Date("2026-07-20T10:00:00Z"); +const actor: SchedulingActor = { + accountId: "account-1", + membershipId: "membership-1", + email: "person@example.test" +}; + +function request( + id: string, + options: { + organizer?: string; + participantStatus?: string; + status?: SchedulingRequest["status"]; + start?: string; + end?: string; + } = {} +): SchedulingRequest { + return { + id, + tenant_id: "tenant-1", + title: id, + timezone: "UTC", + status: options.status ?? "collecting", + organizer_user_id: options.organizer ?? "organizer-elsewhere", + allow_external_participants: true, + allow_participant_updates: true, + result_visibility: "after_close", + calendar_integration_enabled: false, + calendar_freebusy_enabled: false, + calendar_hold_enabled: false, + create_calendar_event_on_decision: false, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + metadata: {}, + slots: [{ + id: `slot-${id}`, + label: id, + start_at: options.start ?? "2026-07-21T09:00:00Z", + end_at: options.end ?? "2026-07-21T10:00:00Z", + timezone: "UTC", + position: 0, + freebusy_conflicts: [], + metadata: {} + }], + participants: options.participantStatus ? [{ + id: `participant-${id}`, + respondent_id: "membership-1", + email: "person@example.test", + participant_type: "internal", + required: true, + status: options.participantStatus, + metadata: {} + }] : [] + }; +} + +test("groups owned, invited, and administrator-only requests without hiding any", () => { + const groups = groupSchedulingRequests([ + request("managed"), + request("mine", { organizer: "account-1" }), + request("invited", { participantStatus: "invited" }) + ], actor, now); + + assert.deepEqual(groups.owned.map((item) => item.id), ["mine"]); + assert.deepEqual(groups.invited.map((item) => item.id), ["invited"]); + assert.deepEqual(groups.other.map((item) => item.id), ["managed"]); +}); + +test("keeps an organizer's active request in the open phase even if they also responded", () => { + const owned = request("mine-and-invited", { + organizer: "account-1", + participantStatus: "responded" + }); + + assert.equal(schedulingSortPhase(owned, actor, now), "unanswered"); +}); + +test("orders unanswered by nearest slot before answered, closed, determined, and past", () => { + const groups = groupSchedulingRequests([ + request("past", { + participantStatus: "invited", + start: "2026-07-18T09:00:00Z", + end: "2026-07-18T10:00:00Z" + }), + request("determined", { participantStatus: "invited", status: "decided" }), + request("closed", { participantStatus: "invited", status: "closed" }), + request("answered", { participantStatus: "responded" }), + request("later", { participantStatus: "invited", start: "2026-07-23T09:00:00Z" }), + request("nearer", { participantStatus: "invited", start: "2026-07-21T09:00:00Z" }) + ], actor, now); + + assert.deepEqual(groups.invited.map((item) => item.id), [ + "nearer", + "later", + "answered", + "closed", + "determined", + "past" + ]); + assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered"); + assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past"); +});