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
|
||||
};
|
||||
Reference in New Issue
Block a user