feat(scheduling): prioritize request worklists
This commit is contained in:
146
webui/src/features/scheduling/schedulingViewModel.ts
Normal file
146
webui/src/features/scheduling/schedulingViewModel.ts
Normal file
@@ -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<SchedulingSortPhase, number> = {
|
||||||
|
unanswered: 0,
|
||||||
|
answered: 1,
|
||||||
|
closed: 2,
|
||||||
|
determined: 3,
|
||||||
|
past: 4
|
||||||
|
};
|
||||||
111
webui/tests/scheduling-view-model.test.ts
Normal file
111
webui/tests/scheduling-view-model.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user