Satu Fail, Setiap Baris
Kita bedah satu fail sebenar dari atas ke bawah: worker/oncall/fairness.ts — enjin yang menjana jadual on-call secara adil. Fokus bukan sekadar "apa ia buat", tetapi CARA & TEKNIK ia ditulis — corak yang anda boleh guna di mana-mana kod.
📑 Kandungan
- Kenapa fail ini paling banyak diajar
- Dokumen niat — komen di atas fail
- Import & types (SlotMode, EffectiveSlot)
- Fungsi tulen & RNG deterministik
- isUnavailable — guard clause
- Model slot — discriminated union
- State — Map & Set untuk laju
- Kredit cuti/joiner — DRY + reka bentuk adil
- Utiliti tarikh & susunan
- Enjin utama generateMonth (greedy)
- tally — reduce ke rekod
- Senarai teknik + seterusnya
Kenapa fail ini paling banyak diajar
Daripada 440 fail, ini fail terbaik untuk belajar cara menulis kod yang baik — bukan kerana paling kompleks, tetapi kerana ia bersih: logik tulen, tiada database, tiada rangkaian, mudah diuji. Setiap teknik di sini boleh dibawa ke mana-mana projek.
Tugasnya: jana jadual on-call sebulan secara adil — bahagi tugas sama rata, patuhi peraturan keselamatan (jangan kerja dua malam berturut), dan boleh diulang (input sama = output sama). Anda tak perlu faham domain on-call untuk belajar tekniknya.
Dokumen niat — komen di atas fail
Sebelum satu baris kod pun, fail ini terangkan strategi & peraturan kerasnya dalam komen. Teknik pertama: tulis "kenapa", bukan "apa".
// Pure fairness engine. No DB, no request context β fully unit-testable.
//
// Strategy: greedy chronological assignment. For each date, for each slot, pick
// the eligible candidate with the lowest fairness score. Fairness combines:
// - cumulative load (heavy shifts weighted), carried across months via history
// - a soft cross-month "skip a heavy shift the month after you did one" penalty
// - a seeded RNG tie-break so identical inputs always yield identical output
//
// HARD filters (R16): unavailability (per-date or leave-range) AND having worked
// the previous calendar day β a 48h no-sleep stretch is a safety issue, never a
// trade-off. If a slot's whole pool is filtered out, the slot is left empty and
// recorded as a conflict for the manager to resolve.
//
// R16 also adds a monthly duty cap (config.maxDutiesPerMonth β relaxed with a
// note when it would leave a day uncovered) and long-leave credit
// (config.leaveCreditMinDays β see leaveCredits()), so returning from maternity
// or a long course is not "repaid" with a catch-up pile-on.
Komen ini tak menerangkan sintaks (kod sendiri jelas). Ia merekod keputusan: kenapa greedy, kenapa ‘No DB’, kenapa 48 jam tanpa tidur tak boleh ditawar. Enam bulan kemudian, ini yang menyelamatkan pembaca.
Import (type-only)
Fail mula dengan membawa masuk jenis-jenis yang ia kerjakan — jenis sahaja, bukan kod.
import type {
Assignment,
HistoryEntry,
HospitalConfig,
MonthSchedule,
Staff,
Unavailability,
} from './types';
import type memberitahu kompiler ‘ini jenis sahaja’ — ia dibuang sepenuhnya selepas build, jadi sifar kos masa jalan. Senarai ini juga mendokumen: fail ini bekerja dengan Staff, HospitalConfig, HistoryEntry, dll.
Fungsi tulen & RNG deterministik
Fail dibina daripada banyak fungsi kecil, satu-tugas. Yang paling menarik: penjana nombor rawak yang boleh diramal, dan matematik tarikh tanpa zon-waktu.
/** Deterministic PRNG (mulberry32) so tie-breaks are reproducible. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/** 'YYYY-MM-DD' for a given y/m(1-indexed)/d, computed without timezone drift. */
export function dateKey(year: number, month1: number, day: number): string {
return `${year}-${pad(month1)}-${pad(day)}`;
}
/** Days in a 1-indexed month. */
export function daysInMonth(year: number, month1: number): number {
return new Date(Date.UTC(year, month1, 0)).getUTCDate();
}
/** Day of week (0=Sun..6=Sat) for a 1-indexed month date. */
export function dayOfWeek(year: number, month1: number, day: number): number {
return new Date(Date.UTC(year, month1 - 1, day)).getUTCDay();
}
/** The 'YYYY-MM' string of the month immediately before the given one. */
function prevMonthPrefix(year: number, month1: number): string {
const m = month1 - 1;
return m === 0 ? `${year - 1}-12` : `${year}-${pad(m)}`;
}
Dua idea. Determinisme: nombor rawak biasa beri hasil berbeza setiap kali (mustahil diuji). mulberry32(seed) beri urutan sama untuk seed sama. Ia juga contoh closure — fungsi yang memulangkan fungsi yang ‘ingat’ a antara panggilan. Tarikh guna UTC: semua kira guna Date.UTC supaya zon waktu komputer tak ganggu hasil. Helah Date.UTC(year, month1, 0) — ‘hari ke-0 bulan berikutnya’ — memberi hari terakhir bulan ini secara automatik.
`${...}`. UTC — masa universal.
isUnavailable — guard clause
Satu fungsi boolean kecil yang menunjukkan cara menulis gelung yang mudah dibaca: keluar awal, elak bersarang.
/** Is staff `id` unavailable on `date`? (date-block or leave-range covers it.) */
export function isUnavailable(
unavail: Unavailability[],
staffId: string,
date: string,
): boolean {
for (const u of unavail) {
if (u.staffId !== staffId) continue;
if (u.kind === 'date' && u.date === date) return true;
if (u.kind === 'range' && date >= u.from && date <= u.to) return true;
}
return false;
}
continue awal (the guard) membuang kes tak-relevan dulu, jadi baris penting tak terbenam dalam if bersarang. Perhatikan perbandingan tarikh: kerana format YYYY-MM-DD tersusun secara semula jadi, date >= u.from berfungsi sebagai perbandingan teks — tak perlu objek Date. Itu sebab format kunci tarikh dipilih teliti di s3.
Jenis slot + effectiveSlotsFor (discriminated union)
Di sini config ‘mentah’ diterjemah jadi senarai slot konkrit untuk satu hari. Tekniknya: jenis union yang ketat, dan bercabang ikut medan penanda kind.
/**
* One concrete slot to fill on a given day. Per-team (2nd-call) slots expand into
* several of these β one per covering team β each with its own `${key}:${team}` id.
*/
/**
* How a concrete slot picks its assignee:
* - fairness: lowest-load eligible candidate (officer, DSA 2nd-call old path).
* - fixed: a constant assignee, no scoring (the 2nd-call specialist).
* - sequence: giliran tetap harian β deterministic daily cycle from an anchor.
* - weeklySequence: giliran tetap mingguan β deterministic MonβSun block cycle.
*/
type SlotMode = 'fairness' | 'fixed' | 'sequence' | 'weeklySequence';
interface EffectiveSlot {
/** Per-team slots carry `${slotKey}:${team}`; others just `slotKey`. */
key: string;
groups: string[];
mode: SlotMode;
team?: string;
/** sequence: the anchor date Β· weeklySequence: the anchor Monday. */
anchorDate?: string;
/** Cycle index occupied by the anchor date/week. */
anchorIndex?: number;
}
/**
* Expand a config's slots into the concrete slots to fill for one day-of-week.
* `poolByDow`/`groups`/`sequence` β one slot; `teamGroups`/`fixed`/`weeklySequence`
* β one slot per covering team. `selfPick` emits NOTHING (staff claim it later).
*/
function effectiveSlotsFor(config: HospitalConfig, dow: number): EffectiveSlot[] {
const out: EffectiveSlot[] = [];
for (const slot of config.slots) {
const src = slot.source;
if (src.kind === 'poolByDow') {
out.push({ key: slot.key, groups: src.poolByDow[dow] ?? [], mode: 'fairness' });
} else if (src.kind === 'groups') {
out.push({ key: slot.key, groups: src.groups, mode: 'fairness' });
} else if (src.kind === 'selfPick') {
continue; // pilih sendiri β engine leaves it empty; no slot emitted.
} else if (src.kind === 'sequence') {
out.push({
key: slot.key, groups: [src.group], mode: 'sequence',
anchorDate: src.anchorDate, anchorIndex: src.anchorIndex,
});
} else if (src.kind === 'weeklySequence') {
// 2nd-call: every covering team's slot is filled every day β the team that
// actually handles a case is decided at CALL TIME by case type (adult β
// OMFS, child β Paeds), not by which team's day this is. See HSR_CONFIG note.
for (const team of Object.keys(src.teamGroups)) {
const groups = src.teamGroups[team];
if (!groups || groups.length === 0) continue;
out.push({
key: `${slot.key}:${team}`, groups, mode: 'weeklySequence', team,
anchorDate: src.anchorMonday, anchorIndex: src.anchorIndex[team] ?? 0,
});
}
} else {
// teamGroups | fixed β same "every team, every day" rule as weeklySequence.
for (const team of Object.keys(src.teamGroups)) {
const groups = src.teamGroups[team];
if (!groups || groups.length === 0) continue;
out.push({
key: `${slot.key}:${team}`, groups,
mode: src.kind === 'fixed' ? 'fixed' : 'fairness', team,
});
}
}
}
return out;
}
SlotMode ialah union: nilai hanya boleh salah satu 4 teks itu — tersilap taip? kompiler tegur. EffectiveSlot ialah interface (medan team? pilihan). Dalam effectiveSlotsFor, setiap slot.source ada medan kind yang ‘membezakan’ jenisnya — discriminated union: selepas src.kind === 'sequence', TS tahu medan lain yang sah untuk jenis itu. Perhatikan selfPick sengaja tak emit apa-apa (keputusan reka bentuk berkomen).
A | B | C) — salah satu daripada. Optional (?) — medan mungkin tiada. Discriminated union — union dengan medan penanda (kind). Object.keys — senarai nama medan objek.
State — Map & Set untuk laju
Enjin perlu menjejak ‘siapa dah buat berapa’. Pilihan struktur data di sini — Map & Set — ialah teknik penting.
/** Staff in any of `groups`. */
function inGroups(staff: Staff[], groups: string[]): Staff[] {
const set = new Set(groups);
return staff.filter((s) => set.has(s.group));
}
interface LoadState {
/** weighted load per (slot|staffId): heavy*heavyWeight + light. */
load: Map<string, number>;
/** staffIds who had a heavy shift in the immediately previous month. */
heavyLastMonth: Set<string>;
/** date(YYYY-MM-DD) β set of staffIds working any slot that day. */
byDate: Map<string, Set<string>>;
/**
* staffId β number of heavy (weekend/24h) shifts assigned THIS month so far.
* Drives the heavy-spread penalty so weekend burden is shared evenly; starts
* at 0 each generation (cross-month fairness is handled by heavyLastMonth).
*/
heavyCount: Map<string, number>;
/** staffId β fairness-slot duties assigned THIS month (drives maxDutiesPerMonth). */
dutiesThisMonth: Map<string, number>;
/** staffId β public holidays worked THIS calendar year (drives holidaySpreadPenalty). */
phThisYear: Map<string, number>;
}
function loadKey(slot: string, staffId: string): string {
return `${slot}|${staffId}`;
}
/** Seed load + last-month-heavy state from prior published history. */
function initLoad(
config: HospitalConfig,
history: HistoryEntry[],
year: number,
month1: number,
): LoadState {
const load = new Map<string, number>();
const heavyLastMonth = new Set<string>();
const byDate = new Map<string, Set<string>>();
const phThisYear = new Map<string, number>();
const prev = prevMonthPrefix(year, month1);
const yearPrefix = `${year}-`;
for (const h of history) {
const w = h.heavy ? config.weights.heavyWeight : 1;
const k = loadKey(h.slot, h.staffId);
load.set(k, (load.get(k) ?? 0) + w);
if (h.heavy && h.date.startsWith(prev)) heavyLastMonth.add(h.staffId);
if (h.date.startsWith(yearPrefix) && h.date in config.holidays) {
phThisYear.set(h.staffId, (phThisYear.get(h.staffId) ?? 0) + 1);
}
let set = byDate.get(h.date);
if (!set) byDate.set(h.date, (set = new Set()));
set.add(h.staffId);
}
return { load, heavyLastMonth, byDate, heavyCount: new Map(), dutiesThisMonth: new Map(), phThisYear };
}
Map = kamus kunci→nilai dengan carian pantas (O(1)); Set = koleksi unik untuk soalan ‘ada atau tiada’. loadKey menggabungkan dua nilai jadi satu kunci komposit teks (`${slot}|${staffId}`). Corak map.set(k, (map.get(k) ?? 0) + n) — ‘ambil-atau-0, tambah, simpan balik’ — berulang di seluruh fail; ?? beri nilai lalai bila tiada.
Kredit cuti/joiner — DRY + reka bentuk adil
Dua fungsi menyelesaikan masalah keadilan yang sama dengan mekanisme sama — dan berkongsi satu pembantu. Inilah prinsip DRY dalam amalan.
Masalah: enjin greedy beri tugas kepada yang beban paling rendah. Seseorang yang baru pulang dari cuti panjang (atau baru menyertai) ada beban hampir sifar — jadi enjin menimbun tugas atasnya sehingga ‘catch up’. Tak adil. Penyelesaian: kreditkan mereka purata markah rakan sekumpulan ketika mereka tiada.
monthlyLoads — pembantu dikongsi (208–228)
/**
* R16 β kredit cuti panjang. For every history month in which a staff member was
* recorded unavailable for at least `config.leaveCreditMinDays` days, credit them
* the average weighted markah their PRESENT group-mates earned that month, per
* slot. Leave is not a debt: without this, someone returning from maternity (or a
* long course) carries near-zero load and the greedy engine piles duties on them
* until they "catch up" β e.g. 10 duties in their first month back.
* Returns loadKey(slot, staffId) β credit, merged into the load state at generate.
*/
/** Weighted markah earned per history month: month('YYYY-MM') β loadKey β markah. */
function monthlyLoads(config: HospitalConfig, history: HistoryEntry[]): Map<string, Map<string, number>> {
const monthLoads = new Map<string, Map<string, number>>();
for (const h of history) {
const month = h.date.slice(0, 7);
let slotMap = monthLoads.get(month);
if (!slotMap) monthLoads.set(month, (slotMap = new Map()));
const k = loadKey(h.slot, h.staffId);
slotMap.set(k, (slotMap.get(k) ?? 0) + (h.heavy ? config.weights.heavyWeight : 1));
}
return monthLoads;
}
leaveCredits — kredit cuti panjang (230–283)
export function leaveCredits(
config: HospitalConfig,
staff: Staff[],
history: HistoryEntry[],
unavail: Unavailability[],
): Map<string, number> {
const credits = new Map<string, number>();
const minDays = config.leaveCreditMinDays;
if (minDays === undefined || history.length === 0) return credits;
const monthLoads = monthlyLoads(config, history);
const groupOf = new Map(staff.map((s) => [s.id, s.group]));
for (const [month, slotMap] of monthLoads) {
const [y, m1] = month.split('-').map(Number);
const total = daysInMonth(y, m1);
const onLeave = new Set<string>();
for (const s of staff) {
let blocked = 0;
for (let d = 1; d <= total; d++) {
if (isUnavailable(unavail, s.id, dateKey(y, m1, d))) blocked++;
}
if (blocked >= minDays) onLeave.add(s.id);
}
if (onLeave.size === 0) continue;
const presentCount = new Map<string, number>();
for (const s of staff) {
if (!onLeave.has(s.id)) presentCount.set(s.group, (presentCount.get(s.group) ?? 0) + 1);
}
// Markah earned that month per (slot, group) by present members. Group
// membership IS pool membership for fairness slots, so the group average is
// exactly "what an average available colleague carried".
const slotGroupSum = new Map<string, number>();
for (const [k, earned] of slotMap) {
const [slot, staffId] = k.split('|');
const group = groupOf.get(staffId);
if (!group || onLeave.has(staffId)) continue;
const sg = `${slot}|${group}`;
slotGroupSum.set(sg, (slotGroupSum.get(sg) ?? 0) + earned);
}
for (const s of staff) {
if (!onLeave.has(s.id)) continue;
const present = presentCount.get(s.group) ?? 0;
if (present === 0) continue;
for (const [sg, sum] of slotGroupSum) {
const [slot, group] = sg.split('|');
if (group !== s.group) continue;
const k = loadKey(slot, s.id);
credits.set(k, (credits.get(k) ?? 0) + sum / present);
}
}
}
return credits;
}
newJoinerCredits — kredit penyertaan baru (285–335)
/**
* R20-C β new-joiner fairness seed. For every history month that ended wholly
* before a staff member's joinedAt, credit them the average weighted markah
* their group-mates earned that month, per slot (same mechanism as
* leaveCredits). Without it a newcomer carries zero load and the greedy engine
* gives them the monthly cap every month until they "catch up" β joining late
* is not a debt. Joining mid-month counts as present for that month (no credit).
*/
export function newJoinerCredits(
config: HospitalConfig,
staff: Staff[],
history: HistoryEntry[],
): Map<string, number> {
const credits = new Map<string, number>();
if (history.length === 0 || !staff.some((s) => s.joinedAt)) return credits;
const monthLoads = monthlyLoads(config, history);
const groupOf = new Map(staff.map((s) => [s.id, s.group]));
for (const [month, slotMap] of monthLoads) {
// Pre-membership = joined in a LATER month than this history month.
const preMember = new Set(
staff.filter((s) => s.joinedAt && s.joinedAt.slice(0, 7) > month).map((s) => s.id),
);
if (preMember.size === 0) continue;
const presentCount = new Map<string, number>();
for (const s of staff) {
if (!preMember.has(s.id)) presentCount.set(s.group, (presentCount.get(s.group) ?? 0) + 1);
}
const slotGroupSum = new Map<string, number>();
for (const [k, earned] of slotMap) {
const [slot, staffId] = k.split('|');
const group = groupOf.get(staffId);
if (!group || preMember.has(staffId)) continue;
const sg = `${slot}|${group}`;
slotGroupSum.set(sg, (slotGroupSum.get(sg) ?? 0) + earned);
}
for (const s of staff) {
if (!preMember.has(s.id)) continue;
const present = presentCount.get(s.group) ?? 0;
if (present === 0) continue;
for (const [sg, sum] of slotGroupSum) {
const [slot, group] = sg.split('|');
if (group !== s.group) continue;
const k = loadKey(slot, s.id);
credits.set(k, (credits.get(k) ?? 0) + sum / present);
}
}
}
return credits;
}
Kedua-dua fungsi kredit ikut struktur sama: cari siapa tiada bulan itu, kira purata markah rakan yang hadir, kreditkan. Logik markah-per-bulan yang sama diekstrak jadi monthlyLoads — ditulis sekali, diguna dua kali. Setiap fungsi bermula dengan guard (pulang awal kalau tiada kerja).
Utiliti tarikh & susunan
Lima pembantu kecil lagi. Dua menonjol: true modulo dan susun tanpa mengubah asal.
/** Shift the calendar date by `delta` days, returning a 'YYYY-MM-DD' key. */
function shiftDate(date: string, delta: number): string {
const [y, m, d] = date.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + delta));
return `${dt.getUTCFullYear()}-${pad(dt.getUTCMonth() + 1)}-${pad(dt.getUTCDate())}`;
}
/** Whole calendar days from `from` to `to` (signed; to β from). Both 'YYYY-MM-DD'. */
function daysBetween(from: string, to: string): number {
const [fy, fm, fd] = from.split('-').map(Number);
const [ty, tm, td] = to.split('-').map(Number);
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000);
}
/** The Monday (ISO week start) of the week containing `date`, as 'YYYY-MM-DD'. */
function mondayOf(date: string): string {
const [y, m, d] = date.split('-').map(Number);
const dow = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Sun..6=Sat
return shiftDate(date, dow === 0 ? -6 : 1 - dow); // Sunβprev Mon, else back to Mon
}
/** True modulo (always 0..m-1, even for negative n) β anchors may precede `date`. */
function trueMod(n: number, m: number): number {
return ((n % m) + m) % m;
}
/** Order a group's pool by its cycle position (sortOrder), id as a stable tie-break. */
function byCycle(pool: Staff[]): Staff[] {
return [...pool].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id.localeCompare(b.id),
);
}
Dalam JavaScript, -1 % 3 beri -1 (bukan 2) — masalah bila anchor kitaran sebelum tarikh. trueMod membetulkannya supaya sentiasa 0..m-1. byCycle menunjukkan dua teknik: [...pool] menyalin array dahulu supaya .sort() (yang mengubah di tempat) tak merosakkan asal; dan || a.id.localeCompare(b.id) memberi tie-break stabil — seri dipecahkan ikut id, jadi hasil sentiasa konsisten.
Enjin utama generateMonth (greedy)
Semua pembantu tadi berkumpul di sini. Ini jantung fail: gelung setiap hari, setiap slot, pilih calon paling adil — selepas menapis yang tak selamat.
9a. Persediaan — satu sumber rawak, satu state (370–394)
/**
* Generate one month's schedule.
* @param month1 1-indexed month (1=Jan..12=Dec).
* @param seed any integer; same inputs + seed β identical output.
*/
export function generateMonth(
config: HospitalConfig,
staff: Staff[],
history: HistoryEntry[],
unavail: Unavailability[],
year: number,
month1: number,
seed = 1,
): MonthSchedule {
const rng = mulberry32(seed);
const state = initLoad(config, history, year, month1);
for (const [k, credit] of leaveCredits(config, staff, history, unavail)) {
state.load.set(k, (state.load.get(k) ?? 0) + credit);
}
for (const [k, credit] of newJoinerCredits(config, staff, history)) {
state.load.set(k, (state.load.get(k) ?? 0) + credit);
}
const assignments: Assignment[] = [];
const conflicts: { date: string; slot: string; reason: string }[] = [];
const total = daysInMonth(year, month1);
9b. Gelung harian + giliran tetap (396–431)
for (let day = 1; day <= total; day++) {
const date = dateKey(year, month1, day);
const dow = dayOfWeek(year, month1, day);
const isHoliday = date in config.holidays;
const heavy = config.weekendDows.includes(dow) || isHoliday;
const todaysAssigned = state.byDate.get(date) ?? new Set<string>();
state.byDate.set(date, todaysAssigned);
const prevDate = shiftDate(date, -1);
const prevAssigned = state.byDate.get(prevDate) ?? new Set<string>();
for (const es of effectiveSlotsFor(config, dow)) {
// Giliran tetap (sequence / weeklySequence): the assignee is pure maths from
// an anchor, ordered by sortOrder. NO unavailability skipping and NO fairness
// β predictability is the whole point; a clash is resolved by a swap, not by
// the engine silently picking someone else.
if (es.mode === 'sequence' || es.mode === 'weeklySequence') {
const cycle = byCycle(inGroups(staff, es.groups));
if (cycle.length === 0) {
const why = 'Tiada staf dalam pool untuk slot ini pada hari ini.';
assignments.push({ date, slot: es.key, staffId: null, heavy, reason: why, team: es.team });
conflicts.push({ date, slot: es.key, reason: why });
continue;
}
const steps =
es.mode === 'sequence'
? daysBetween(es.anchorDate!, date)
: Math.round(daysBetween(es.anchorDate!, mondayOf(date)) / 7);
const chosen = cycle[trueMod((es.anchorIndex ?? 0) + steps, cycle.length)];
todaysAssigned.add(chosen.id);
assignments.push({
date, slot: es.key, staffId: chosen.id, heavy,
reason: `${chosen.name}: giliran tetap${heavy ? ' (24j)' : ''}`,
team: es.team,
});
continue;
}
9c. Kelayakan — buang yang cuti / sudah bertugas (433–446)
const pool = inGroups(staff, es.groups);
const eligible = pool.filter(
(s) => !isUnavailable(unavail, s.id, date) && !todaysAssigned.has(s.id),
);
if (eligible.length === 0) {
const why =
pool.length === 0
? 'Tiada staf dalam pool untuk slot ini pada hari ini.'
: 'Semua calon tidak tersedia (cuti / sudah bertugas hari ini).';
assignments.push({ date, slot: es.key, staffId: null, heavy, reason: why, team: es.team });
conflicts.push({ date, slot: es.key, reason: why });
continue;
}
9d. Pakar tetap (fixed) — pilihan deterministik (448–462)
// Fixed specialist: constant assignee, no fairness scoring and no load
// accumulation β pick deterministically by id so output is reproducible.
if (es.mode === 'fixed') {
const chosen = [...eligible].sort((a, b) => a.id.localeCompare(b.id))[0];
todaysAssigned.add(chosen.id);
assignments.push({
date,
slot: es.key,
staffId: chosen.id,
heavy,
reason: `${chosen.name}: pakar tetap${heavy ? ' (24j)' : ''}`,
team: es.team,
});
continue;
}
9e. Tapisan KERAS R16 + had bulanan (464–486)
// R16 hard rule β tiada hari berturut. Working yesterday (any slot) is a
// hard filter for fairness slots: 48h without sleep is a safety issue, not
// a preference. An empty pool here becomes a manager-visible gap β the
// engine never trades sleep for coverage.
const rested = eligible.filter((s) => !prevAssigned.has(s.id));
if (rested.length === 0) {
const why = 'Semua calon bertugas semalam β peraturan tiada hari berturut.';
assignments.push({ date, slot: es.key, staffId: null, heavy, reason: why, team: es.team });
conflicts.push({ date, slot: es.key, reason: why });
continue;
}
// R16 β had bulanan. Coverage beats the cap: when every rested candidate
// is already at it, relax with a note instead of leaving the day empty.
let candidates = rested;
let capNote = '';
if (config.maxDutiesPerMonth !== undefined) {
const underCap = rested.filter(
(s) => (state.dutiesThisMonth.get(s.id) ?? 0) < config.maxDutiesPerMonth!,
);
if (underCap.length > 0) candidates = underCap;
else capNote = ', melebihi had bulanan (tiada calon lain)';
}
9f. Pemarkahan keadilan + tie-break (488–524)
let best: Staff | null = null;
let bestScore = Infinity;
let bestNote = '';
for (const cand of candidates) {
const base = state.load.get(loadKey(es.key, cand.id)) ?? 0;
let score = base;
const notes: string[] = [`beban=${base.toFixed(0)}`];
if (isHoliday && config.weights.holidaySpreadPenalty !== undefined) {
// R17: the next PH goes to whoever has done the fewest PHs this
// calendar year. Outranks the within-month heavy spread below.
const phCount = state.phThisYear.get(cand.id) ?? 0;
if (phCount > 0) {
score += phCount * config.weights.holidaySpreadPenalty;
notes.push('agih cuti umum setahun');
}
}
if (heavy) {
// Share weekends evenly: anyone who already has a heavy shift this month
// is pushed far down the list, so the next weekend goes to someone with
// none until everyone has had one. Dominates ordinary load differences.
const hc = state.heavyCount.get(cand.id) ?? 0;
if (hc > 0) {
score += hc * config.weights.heavySpreadPenalty;
notes.push('agih hujung-minggu rata');
}
}
if (heavy && state.heavyLastMonth.has(cand.id)) {
score += config.weights.skipWeekendPenalty;
notes.push('elak ulang hujung-minggu');
}
score += rng() * 0.001; // deterministic tie-break, never overrides real signal
if (score < bestScore) {
best = cand;
bestScore = score;
bestNote = notes.join(', ');
}
}
9g. Sahkan pilihan + kemas state + slot sampingan (526–557)
// best is non-null: candidates.length > 0 guaranteed a candidate above.
const chosen = best as Staff;
const k = loadKey(es.key, chosen.id);
state.load.set(k, (state.load.get(k) ?? 0) + (heavy ? config.weights.heavyWeight : 1));
if (heavy) state.heavyCount.set(chosen.id, (state.heavyCount.get(chosen.id) ?? 0) + 1);
if (isHoliday) state.phThisYear.set(chosen.id, (state.phThisYear.get(chosen.id) ?? 0) + 1);
state.dutiesThisMonth.set(chosen.id, (state.dutiesThisMonth.get(chosen.id) ?? 0) + 1);
todaysAssigned.add(chosen.id);
assignments.push({
date,
slot: es.key,
staffId: chosen.id,
heavy,
reason: `${chosen.name}: ${bestNote}${capNote}${heavy ? ' (24j)' : ''}`,
team: es.team,
});
// KPSR / KPTM officer day: the unit covers, but the actual doctor on call is
// chosen by that unit. Emit an EMPTY `primer_doctor:<unit>` slot for the day so
// the unit's doctor can self-fill it later. Not a conflict β an expected blank.
if (chosen.group === 'primer') {
const unit = chosen.name.toLowerCase(); // 'kpsr' | 'kptm' β matches kpsr_doctor/kptm_doctor
assignments.push({
date,
slot: `primer_doctor:${unit}`,
staffId: null,
heavy,
reason: `Doktor unit ${chosen.name} akan pilih sendiri.`,
team: unit,
});
}
}
9h. Tutup gelung + pulangkan jadual (558–561)
}
return { hospitalId: config.hospitalId, year, month: month1, assignments, conflicts };
}
Tiga keputusan reka bentuk menonjol. Keselamatan ialah tapisan keras, bukan markah: yang cuti atau kerja semalam dibuang dahulu (9c, 9e) — tak pernah ditandingi dengan skor. Slot tanpa calon sah dibiar kosong + direkod konflik untuk pengurus — enjin tak pernah memaksa pilihan tak selamat. Rawak hanya pemecah seri (rng() * 0.001) — tak pernah mengatasi isyarat keadilan sebenar. Setiap tugasan membawa reason, jadi pengurus faham kenapa.
tally — reduce ke rekod
Fungsi terakhir: kira jumlah tugas setiap orang untuk papan pemuka. Contoh bersih ‘lipat senarai jadi ringkasan’.
/** Per-(slot,staff) tally for the fairness dashboard / "Kiraan Tugas". */
export function tally(schedule: MonthSchedule): Record<string, { heavy: number; light: number }> {
const out: Record<string, { heavy: number; light: number }> = {};
for (const a of schedule.assignments) {
if (!a.staffId) continue;
const k = loadKey(a.slot, a.staffId);
const t = (out[k] ??= { heavy: 0, light: 0 });
if (a.heavy) t.heavy++;
else t.light++;
}
return out;
}
Corak reduce: mula bekas kosong, gelung, kumpul jumlah. out[k] ??= {...} (nullish assignment) mencipta entri lalai hanya kalau belum ada — satu baris ganti tiga baris if (!out[k]) out[k] = {...}. Bermula dengan guard if (!a.staffId) continue.
Senarai teknik + seterusnya
Satu fail, banyak teknik boleh-bawa. Inilah yang anda baru pelajari — semuanya transferable ke mana-mana projek.
| Teknik | Di mana dalam fail |
|---|---|
| Komen "kenapa" + dokumen niat di atas | s1 (baris 1–17) |
| Union & interface (keadaan haram mustahil) | s2 (SlotMode, EffectiveSlot) |
| Pure function + determinisme (seed RNG) | s3 (mulberry32) |
| Closure + template string + tarikh UTC | s3 (dateKey, daysInMonth) |
| Guard clause (keluar awal) | s4 (isUnavailable), s9c, s10 |
| Discriminated union (cabang ikut kind) | s5 (effectiveSlotsFor) |
| Map/Set + kunci komposit + ?? default | s6 (LoadState, loadKey) |
| DRY (ekstrak pembantu dikongsi) | s7 (monthlyLoads) |
| True modulo + salin-sebelum-sort + tie-break | s8 (trueMod, byCycle) |
| Tapis-keras-dahulu, markah-kemudian; min-by-score | s9 (generateMonth) |
| Gagal-dengan-jelas (konflik, bukan senyap) | s9c/9e |
| Reduce + ??= default | s10 (tally) |
Buka worker/oncall/fairness.ts dan terangkan fungsi generateMonth baris demi baris penuh. Saya faham idea greedy + tapisan keras dari panduan ini — sekarang tunjuk setiap baris yang saya lompat.
Dalam fairness.ts, kenapa rng() didarab 0.001 dan bukan nombor besar? Apa jadi pada hasil kalau saya buang baris tie-break itu?