-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoverlap.ts
More file actions
121 lines (107 loc) · 3.7 KB
/
Copy pathoverlap.ts
File metadata and controls
121 lines (107 loc) · 3.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { BLOCK_ORDER, generateSlots, type Slot } from "./slots";
import type { AvailabilityRow, Member, SlotBlock } from "./types";
export interface OverlapWindow {
key: string;
date: string;
blocks: SlotBlock[];
hours: number;
availableMemberIds: string[];
busyMemberIds: string[];
pendingMemberIds: string[];
score: number;
}
export interface OverlapResult {
best: OverlapWindow | null;
alternatives: OverlapWindow[];
hasAnyOverlap: boolean;
respondedCount: number;
totalCount: number;
}
/**
* Ranks candidate meeting windows by (1) how many people are free and
* (2) how long the overlap runs — "most people, longest stretch" per the
* product spec. Adjacent same-day blocks with an identical set of available
* members are merged into a single window so a full free day scores higher
* than three disconnected slots would.
*/
export function computeOverlap(
plan: Parameters<typeof generateSlots>[0],
members: Member[],
availability: AvailabilityRow[]
): OverlapResult {
const slots = generateSlots(plan);
const respondedIds = new Set(
members.filter((m) => m.responded_at).map((m) => m.id)
);
const pendingIds = members.filter((m) => !m.responded_at).map((m) => m.id);
const availabilityBySlot = new Map<string, Map<string, "available" | "busy">>();
for (const row of availability) {
if (!availabilityBySlot.has(row.slot_id)) {
availabilityBySlot.set(row.slot_id, new Map());
}
availabilityBySlot.get(row.slot_id)!.set(row.member_id, row.status);
}
const slotInfo = slots.map((slot) => {
const statuses = availabilityBySlot.get(slot.id);
const availableMemberIds: string[] = [];
const busyMemberIds: string[] = [];
for (const memberId of respondedIds) {
const status = statuses?.get(memberId) ?? "busy";
if (status === "available") availableMemberIds.push(memberId);
else busyMemberIds.push(memberId);
}
return { slot, availableMemberIds, busyMemberIds };
});
const byDate = new Map<string, typeof slotInfo>();
for (const info of slotInfo) {
if (!byDate.has(info.slot.date)) byDate.set(info.slot.date, []);
byDate.get(info.slot.date)!.push(info);
}
const windows: OverlapWindow[] = [];
for (const [date, infos] of byDate) {
infos.sort(
(a, b) => BLOCK_ORDER.indexOf(a.slot.block) - BLOCK_ORDER.indexOf(b.slot.block)
);
let run: typeof infos = [];
const flush = () => {
if (run.length === 0) return;
const availableMemberIds = [...run[0].availableMemberIds].sort();
const busyMemberIds = [...run[0].busyMemberIds].sort();
const hours = run.reduce((sum, r) => sum + r.slot.hours, 0);
windows.push({
key: `${date}_${run[0].slot.block}-${run[run.length - 1].slot.block}`,
date,
blocks: run.map((r) => r.slot.block),
hours,
availableMemberIds,
busyMemberIds,
pendingMemberIds: pendingIds,
score: availableMemberIds.length * 1000 + hours,
});
run = [];
};
for (const info of infos) {
const key = [...info.availableMemberIds].sort().join(",");
const runKey = run.length
? [...run[0].availableMemberIds].sort().join(",")
: null;
if (run.length === 0 || key === runKey) {
run.push(info);
} else {
flush();
run.push(info);
}
}
flush();
}
windows.sort((a, b) => b.score - a.score || a.date.localeCompare(b.date));
const withOverlap = windows.filter((w) => w.availableMemberIds.length > 0);
return {
best: withOverlap[0] ?? null,
alternatives: withOverlap.slice(1, 4),
hasAnyOverlap: withOverlap.length > 0,
respondedCount: respondedIds.size,
totalCount: members.length,
};
}
export type { Slot };