昨天介紹完兩個可愛的鎖,今天要換另一個曾經讓我腦袋大打結的地方了(笑)。
先想像一下:
今天你是活動建立者,想辦一場活動,時間可以落在:
09:00~18:00
但你還不知道大家到底哪一段最有空。
你真正想知道的其實不是:
「哪一個人選了哪一格?」
而是:
「在這整個時間範圍裡,哪一段連續時間最多人都有空?」
BuJo 有一種活動情境,就是為了解決這件事。
參與者不用從建立者列好的選項裡投票,而是直接回報:
「我這一整段時間都有空。」
例如:
Alice:18:00 ───────── 20:00
Bob: 19:00 ───────── 21:00
人類一眼就看得出來:
19:00~20:00
→ Alice、Bob 都有空
但程式要怎麼從大家各自填的 Range(時間範圍)裡,整理出這種結果?
BuJo 的做法,是先把整個時間範圍切成固定的小格,一格一格算有多少人有空,再把結果整理成建立者真正可以拿來決定活動時間的候選區間。
今天就來拆這套候選時段重疊計算演算法。
先從最簡單的例子開始。
假設今天可以選的時間範圍是:
18:00~21:00
Alice 和 Bob 分別回報:
Alice:18:00~20:00
Bob: 19:00~21:00
BuJo 的 computeRangeRanking() 不是直接拿兩條 Range 去猜「最佳時間」。
它會先把整個基準範圍切成固定的 60 分鐘小格:
18:00~19:00
19:00~20:00
20:00~21:00
接著一格一格問:
「這一格,和哪些人的可用時間有重疊?」
所以最後會得到:
| 時段 | 有空的人 | count |
|---|---|---|
| 18:00~19:00 | Alice | 1 |
| 19:00~20:00 | Alice、Bob | 2 |
| 20:00~21:00 | Bob | 1 |
這樣原本兩條長短不一的 Range,就被整理成了一組可以直接比較的小格子。
最後再依 count 排序:
19:00~20:00 → 2 人
18:00~19:00 → 1 人
20:00~21:00 → 1 人
這樣建立者就能先看出:
整個可選範圍裡,哪些小格有最多人有空。
直接進 computeRangeRanking() 看最清楚。
先只看「切格」這一段:
const segments = [];
let segStart = new Date(windowStart);
while (segStart < windowEnd) {
const segEnd = new Date(
Math.min(
// 每次往後切 60 分鐘
segStart.getTime() + 60 * 60 * 1000,
// 最後一格不能超過整個可選範圍
windowEnd.getTime(),
),
);
segments.push({
slot_start: segStart,
slot_end: segEnd,
});
segStart = segEnd;
}
假設:
windowStart = 18:00
windowEnd = 21:00
跑完之後,就會得到:
18:00~19:00
19:00~20:00
20:00~21:00
這裡採用的是一個很務實的做法:
先把連續時間標準化成固定大小的小格,再逐格計算。
因為每個人回報的 Range 都可能長得不一樣。
有人可能填:
18:00~20:00
另一個人可能填:
19:00~21:00
如果先切成同一套基準,每一格就都能用相同規則判斷。
格子切好之後,下一步就是計算每一格的 Supporters(支持者)。
BuJo 的 Code 是這樣:
const counted = segments.map((seg) => {
// 找出哪些人的可用 Range 和這一格有重疊
const covering = ranges.filter(
(r) =>
r.start < seg.slot_end &&
r.end > seg.slot_start,
);
// 同一個人即使有多筆 Range 涵蓋這一格
// 也只能算一個人
const supporterIds = new Set(
covering.map((r) => r.user_id),
);
return {
...seg,
count: supporterIds.size,
supporterIds,
};
});
這裡有兩個很重要的東西:
count
→ 這一格有幾個人支持
supporterIds
→ 到底是哪幾個人支持
這樣建立者最後看到的不只有「幾個人有空」,也能知道這段時間實際是哪些人可以參加。
而 supporterIds 後面還有另一個用途:
判斷相鄰的時間到底能不能安全合併。
60 分鐘只是 BuJo 拿來計算的單位,不代表建立者最後只能選 1 小時。
如果 Alice 從 12:00~15:00 都有空:
12:00~13:00 → Alice
13:00~14:00 → Alice
14:00~15:00 → Alice
對建立者來說,更合理的結果其實是:
12:00~15:00 → Alice
所以切格之後,BuJo 還要把可以安全合併的相鄰區段重新接回去。
但這裡有一個小陷阱:
09:00~10:00 → Alice
10:00~11:00 → Bob
雖然兩格的 count 都是 1,也不能合併。
因為:
人數一樣,不代表是同一群人。
所以 BuJo 合併時除了看時間相鄰、count 相同,還會再確認 supporterIds 完全相同。
BuJo 把這個判斷放在 mergeAdjacentSameCount():
function mergeAdjacentSameCount(countedSegments) {
const merged = [];
for (const seg of countedSegments) {
// 沒有人有空的格子不用顯示
if (seg.count === 0) continue;
const last = merged[merged.length - 1];
if (
last &&
// ① 支持人數相同
last.count === seg.count &&
// ② 時間真的相鄰
last.slot_end.getTime() ===
seg.slot_start.getTime() &&
// ③ 支持者也必須是同一群人
sameSupporterSet(
last.supporterIds,
seg.supporterIds,
)
) {
// 三個條件都成立,才把時間往後延長
last.slot_end = seg.slot_end;
} else {
merged.push({ ...seg });
}
}
return merged;
}
所以真正可以合併的條件是:
count 相同
+
時間相鄰
+
supporterIds 完全相同
↓
才可以合併
例如:
09:00~10:00
count = 1
supporters = Alice
10:00~11:00
count = 1
supporters = Alice
這時就可以安全地合併成:
09:00~11:00
count = 1
supporters = Alice
但如果支持者不同,就算 count 一模一樣,也必須保持兩筆。
sameSupporterSet():比的不只是人數前面知道了,光看 count 還不夠。
例如:
09:00~10:00
[Alice, Bob]
10:00~11:00
[Alice, Carol]
兩個時段明明都是:
count = 2
但有空的人並不是同一群。
所以 BuJo 還會用 sameSupporterSet() 檢查:
function sameSupporterSet(a, b) {
// 人數不同,一定不是同一群人
if (a.size !== b.size) return false;
// 每一個人都要同時存在於另一組
for (const id of a) {
if (!b.has(id)) return false;
}
return true;
}
它做的事情可以直接理解成:
不只要確認「都是 2 個人」,還要確認「是不是同樣這 2 個人」。
只有支持者完全相同,相鄰的兩段時間才可以繼續合併。
一路拆到這裡,computeRangeRanking() 做的事情其實可以整理成:
大家回報各自的可用 Range
↓
把整個時間範圍切成 60 分鐘一格
↓
逐格找出有哪些 Range 覆蓋
↓
用 supporterIds 去重
↓
得到實際支持人數 count
↓
相鄰格子嘗試合併
↓
時間相鄰?
count 相同?
supporterIds 相同?
↓
三個都成立才合併
↓
最後依 count 由高到低排序
所以它不是單純:
「數一數哪個時間最多票。」
而是先把連續時間拆成統一的小單位,再保留每一格背後真正的支持者。
最後再把切碎的小格整理回:
建立者真正能拿來選活動時間的候選區間。
BuJo 也替這個合併邊界留下了 Test。
it("票數相同但支持者不同不會合併", () => {
const windowStart =
new Date("2026-08-01T09:00:00Z");
const windowEnd =
new Date("2026-08-01T11:00:00Z");
const ranges = [
{
start: new Date("2026-08-01T09:00:00Z"),
end: new Date("2026-08-01T10:00:00Z"),
user_id: "alice",
},
{
start: new Date("2026-08-01T10:00:00Z"),
end: new Date("2026-08-01T11:00:00Z"),
user_id: "bob",
},
];
const result = computeRangeRanking(
ranges,
windowStart,
windowEnd,
2,
participantsById,
);
expect(result).toEqual([
expect.objectContaining({
slot_start:
new Date("2026-08-01T09:00:00Z"),
slot_end:
new Date("2026-08-01T10:00:00Z"),
count: 1,
}),
expect.objectContaining({
slot_start:
new Date("2026-08-01T10:00:00Z"),
slot_end:
new Date("2026-08-01T11:00:00Z"),
count: 1,
}),
]);
});
這個 Test 驗證的就是:
兩格時間相鄰
+
count 都是 1
+
supporterIds 不同
↓
不能合併
也就是把「切碎之後要重新合併,但不能合錯人」這個邊界直接留成一條 Test 保護起來。
一開始我真的以為,這個功能就是把大家有空的時間丟進去,再算出重疊最多的區間而已。
結果真的做下去才發現,事情沒有這麼單純。
假設活動的可選範圍就是整整一天,就算系統把它切成一格一格來算,也總不能最後真的把 24 個選項全部丟給建立者看(笑)。
所以除了「算得對」,後面還要再想:
怎麼把這些結果整理成人真的看得懂、也方便做決定的樣子。
這也是我這次很有感的地方。
原本只是想幫大家把時間配在一起,最後才發現,真正難的其實不只演算法,還有怎麼把機器算出來的東西,變成使用者真的用得上的結果。
候選時段終於算完了,核心開發這一關也差不多走到尾聲啦~
明天開始,我們要把視線從「Code 怎麼算」移到另一個問題:
Code 寫好了,要怎麼穩定地送到真正運行的環境?
下一篇,就來進入 Deployment(部署)和 CI/CD 的世界。