Panduan Kod 04 · fairness.ts
← Kod 02
Panduan Kod · Bahagian 04  ·  Bedah Satu Fail

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.

🧹 Pure function 🎲 RNG deterministik 🎯 Discriminated union 📑 Map & Set 🛡️ Guard clause Greedy
00 Mula

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.

Cara baca halaman ini (PENTING) Setiap bahagian menunjukkan kod SEBENAR, disalin tepat daripada worker/oncall/fairness.ts (tiada diubah, tiada dipendekkan) — diikuti penjelasan dan kotak Teknik. Buka fail itu di editor anda di sebelah; ia sepatutnya sama baris demi baris. Label setiap blok menunjukkan nombor barisnya.
Versi Disalin daripada keadaan fail pada 2026-06-18. Kalau anda ubah fairness.ts, blok di sini tidak berubah sendiri — jana semula atau bandingkan dengan editor.
01 Baris 1–17

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".

fairness.ts · baris 1–17
// 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.

Teknik — komen "kenapa", bukan "apa" i++ // tambah i tak berguna; // 48h tanpa tidur = isu keselamatan sangat berguna. Letak ringkasan strategi di atas fail yang rumit.
Perkataan baru Pure (tulen) — hasil bergantung HANYA pada input, tiada kesan luar. Greedy — pilih terbaik setiap langkah. Unit-testable — mudah diuji berasingan kerana tiada pergantungan luar.
02 Baris 19–26

Import (type-only)

Fail mula dengan membawa masuk jenis-jenis yang ia kerjakan — jenis sahaja, bukan kod.

fairness.ts · baris 19–26
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.

Teknik — import type Bila anda import hanya untuk anotasi jenis (bukan nilai masa jalan), guna import type — lebih jelas niat, dan tiada kesan pada bundle akhir.
Perkataan baru Type — takrifan bentuk data. import type — import jenis sahaja.
03 Baris 28–63

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.

fairness.ts · baris 28–63
/** 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.

Teknik — determinisme + fungsi-pulang-fungsi Untuk kod boleh-uji, elak rawak/masa/global tak terkawal — suntik seed. Pecahkan kerja jadi fungsi kecil bernama jelas dengan JSDoc.
Perkataan baru PRNG / seed — penjana pseudo-rawak; seed = benih. Deterministik — input sama → output sama. Closure — fungsi yang kekal akses pemboleh ubah luarnya. Template string`${...}`. UTC — masa universal.
04 Baris 65–77

isUnavailable — guard clause

Satu fungsi boolean kecil yang menunjukkan cara menulis gelung yang mudah dibaca: keluar awal, elak bersarang.

fairness.ts · baris 65–77
/** 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.

Teknik — guard clause (keluar awal) Buang kes mudah di awal dengan continue/return, supaya logik utama kekal rata (maks ~3 lapis). Senang dibaca dari atas ke bawah.
05 Baris 79–149

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.

fairness.ts · baris 79–149
/**
 * 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).

Teknik — union + discriminated union ("buat keadaan haram mustahil") Daripada mode: string (boleh apa-apa), guna union 4-nilai supaya nilai salah tak boleh wujud. Beri data berbilang-bentuk satu medan penanda (kind) dan cabang atasnya — kompiler kawal setiap cabang.
Perkataan baru Union (A | B | C) — salah satu daripada. Optional (?) — medan mungkin tiada. Discriminated union — union dengan medan penanda (kind). Object.keys — senarai nama medan objek.
06 Baris 151–206

State — Map & Set untuk laju

Enjin perlu menjejak ‘siapa dah buat berapa’. Pilihan struktur data di sini — Map & Set — ialah teknik penting.

fairness.ts · baris 151–206
/** 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.

Teknik — pilih struktur data yang betul Kiraan/jumlah ikut kunci → Map. Keahlian ‘ada/tiada’ → Set. Mengelak array.find(...) berulang (lambat) = beza kod laju vs perlahan.
Perkataan baru Map — kamus kunci→nilai. Set — koleksi unik. Kunci komposit — gabung beberapa nilai jadi satu kunci. ?? (nullish coalescing) — guna kiri; kalau null/undefined, guna kanan. O(1) — masa carian tetap.
07 Baris 208–335

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)

fairness.ts · baris 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)

fairness.ts · baris 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)

fairness.ts · baris 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).

Teknik — DRY + nama yang mendokumen Bila dua fungsi berkongsi logik, ekstrak ke pembantu bernama (monthlyLoads). Nama jelas (leaveCredits, newJoinerCredits) + komen ‘kenapa’ menjadikan keputusan reka bentuk (keadilan) jelas tanpa membaca setiap baris.
Perkataan baru DRY (Don't Repeat Yourself) — jangan salin logik; ekstrak. Helper — fungsi kecil penyokong. Edge case — kes hujung (cth sejarah kosong) dikendali guard.
08 Baris 337–368

Utiliti tarikh & susunan

Lima pembantu kecil lagi. Dua menonjol: true modulo dan susun tanpa mengubah asal.

fairness.ts · baris 337–368
/** 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.

Teknik — jangan ubah input; pecah seri secara stabil .sort()/.reverse() mengubah array di tempat — salin dengan [...arr] dahulu. Sentiasa beri tie-break deterministik supaya output boleh diramal.
Perkataan baru Modulo (%) — baki bahagi. Spread ([...arr]) — salin. Mutate — ubah data asal (kadang bahaya). Stable sort / tie-break — cara konsisten menyusun nilai sama.
09 Baris 370–561

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)

fairness.ts · baris 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)

fairness.ts · baris 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)

fairness.ts · baris 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)

fairness.ts · baris 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)

fairness.ts · baris 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)

fairness.ts · baris 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)

fairness.ts · baris 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)

fairness.ts · baris 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.

Teknik — tapis-keras-dahulu, markah-kemudian + "min by score" Asingkan peraturan keras (filter) daripada keutamaan lembut (markah). Corak cari minimum: mula bestScore = Infinity, gelung, ganti bila jumpa lebih kecil. Sentiasa rekod sebab keputusan automatik supaya boleh diaudit.
Keputusan reka bentuk yang penting Bila tiada calon selamat, fail ini memilih gagal dengan jelas (slot kosong + konflik) daripada gagal senyap (pilih orang tak cukup tidur). Kod baik menjadikan kegagalan kelihatan.
Perkataan baru filter — array baru dengan ahli yang lulus ujian. Hard vs soft rule — mutlak vs boleh-tolak-ansur. Min-by-score — corak cari terkecil. as Staff — type assertion: ‘saya pasti ini bukan null’.
10 Baris 563–574

tally — reduce ke rekod

Fungsi terakhir: kira jumlah tugas setiap orang untuk papan pemuka. Contoh bersih ‘lipat senarai jadi ringkasan’.

fairness.ts · baris 563–574
/** 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.

Teknik — reduce + default ringkas ‘Lipat’ senarai jadi ringkasan: bekas kosong → gelung → kumpul. ??= beri default sekali-baris.
11 Tamat

Senarai teknik + seterusnya

Satu fail, banyak teknik boleh-bawa. Inilah yang anda baru pelajari — semuanya transferable ke mana-mana projek.

TeknikDi mana dalam fail
Komen "kenapa" + dokumen niat di atass1 (baris 1–17)
Union & interface (keadaan haram mustahil)s2 (SlotMode, EffectiveSlot)
Pure function + determinisme (seed RNG)s3 (mulberry32)
Closure + template string + tarikh UTCs3 (dateKey, daysInMonth)
Guard clause (keluar awal)s4 (isUnavailable), s9c, s10
Discriminated union (cabang ikut kind)s5 (effectiveSlotsFor)
Map/Set + kunci komposit + ?? defaults6 (LoadState, loadKey)
DRY (ekstrak pembantu dikongsi)s7 (monthlyLoads)
True modulo + salin-sebelum-sort + tie-breaks8 (trueMod, byCycle)
Tapis-keras-dahulu, markah-kemudian; min-by-scores9 (generateMonth)
Gagal-dengan-jelas (konflik, bukan senyap)s9c/9e
Reduce + ??= defaults10 (tally)
Benang merah seluruh fail Setiap teknik berkhidmat pada satu matlamat: kod yang boleh diramal, diuji, dan diaudit. Determinisme, pure functions, tapisan keras yang jelas, dan "sebab" pada setiap keputusan — itu yang membezakan kod yang "jalan" dengan kod yang boleh dipercayai dalam sistem klinikal.
Tanya Claude Code (dalami fail hidup) 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.
Uji kefahaman Dalam fairness.ts, kenapa rng() didarab 0.001 dan bukan nombor besar? Apa jadi pada hasil kalau saya buang baris tie-break itu?