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 kemas dan baik. Ia fail yang paling bersih: fungsinya hanya mengira tanpa kesan sampingan (pure function), tidak mengacau pangkalan data, dan paling penting, mudah diuji. Teknik di sini sangat berguna untuk mana-mana projek anda nanti.

Tugas utamanya: menjana jadual on-call secara adil — bahagikan tugas sama rata, patuhi syarat keselamatan staf (jangan paksa kerja dua malam berturut-turut), dan sentiasa memberikan keputusan jadual yang sama jika dimasukkan data yang sama. Anda tak perlu faham kerja on-call hospital pun untuk pelajari teknik pengekodan ini.

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

Merekod Niat/Tujuan — komen di atas fail

Sebelum sebarang kod ditulis, fail ini menerangkan strategi & syarat wajib melalui komen. Teknik pertama: sentiasa catat "kenapa" kod ini ditulis, bukan "apa" yang ia buat.

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 di atas tidak menerangkan cara kod berfungsi secara terperinci (kerana kod itu sendiri sudah jelas). Sebaliknya, ia merekodkan keputusan penting: kenapa algoritma pilihan-terbaik (greedy) digunakan, kenapa pangkalan data (DB) tidak disentuh, dan kenapa staf dilarang bekerja 48 jam tanpa rehat walau apa jua alasan. Enam bulan kemudian, nota ringkas inilah yang akan menyelamatkan anda dari pening kepala.

Teknik — komen "kenapa", bukan "apa" Komen seperti i++ // tambah i langsung tidak berguna. Tetapi komen seperti // 48h tanpa tidur = isu keselamatan sangat membantu. Sentiasa letakkan ringkasan tujuan di atas fail yang rumit.
Perkataan baru Pure function — Fungsi bebas kesan; hasilnya bergantung HANYA pada data yang diberi (input), tanpa mengubah atau bergantung pada benda di luar. Greedy algorithm — Algoritma yang secara automatik memilih pilihan terbaik pada setiap langkah (tanpa melihat ke depan). Unit-testable — Kod yang mudah diuji secara berasingan kerana ia bebas dari sistem 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

Pure function & Penjana nombor rawak (RNG) tetap

Fail ini dibina menggunakan banyak fungsi kecil yang fokus pada satu tugas (pure functions). Yang paling menarik di sini: penjana nombor rawak yang keputusannya boleh diramal (tetap), dan pengiraan tarikh yang bebas daripada zon waktu komputer.

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)}`;
}

Terdapat dua idea utama di sini. Pertama, Penjana Rawak Tetap (Determinisme): Fungsi rawak biasa akan memberikan hasil yang berbeza setiap kali dipanggil (menjadikannya sukar diuji). Namun, mulberry32(seed) akan sentiasa memberikan susunan nombor yang sama asalkan 'benih' (seed) yang diberi adalah sama. Ini juga merupakan contoh penggunaan fungsi bersarang (closure) — sebuah fungsi yang menghasilkan fungsi lain dan 'ingat' nilai asalnya antara setiap panggilan. Kedua, Pengiraan Tarikh menggunakan UTC: Pengiraan dibuat dengan standard Date.UTC agar zon waktu komputer tempatan tidak mengganggu hasil jadual.

Teknik — Elak rawak dan masa yang berubah-ubah Untuk membina sistem yang mudah diuji keberkesanannya, pastikan anda menyalurkan data (seperti 'benih' nombor rawak) secara terkawal. Pecahkan kerja kepada fungsi-fungsi kecil dengan nama yang jelas.
Perkataan baru PRNG / seed — Penjana nombor rawak berkomputer; seed adalah 'benih' asalnya. Deterministik — Jika diberikan data input yang sama, hasil keluarannya mesti sama setiap masa. Closure (Fungsi Bersarang) — Fungsi di dalam fungsi yang sentiasa ingat nilai asal dari luar.
04 Baris 65–77

isUnavailable — Tapisan awal (guard clause)

Satu fungsi boolean kecil yang menunjukkan cara menulis gelung yang mudah dibaca: keluar awal, elak syarat bersarang (nested) yang berserabut.

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;
}

Penggunaan continue seawal mungkin (dipanggil guard atau tapisan awal) bertujuan membuang kes yang tidak relevan terlebih dahulu. Dengan ini, baris kod yang paling penting tidak terperangkap di dalam kurungan if yang berlapis-lapis. Perhatikan juga perbandingan tarikh: kerana format YYYY-MM-DD tersusun mengikut urutan secara semula jadi, kita boleh guna date >= u.from terus tanpa perlu membina objek Date yang rumit.

Teknik — Tapisan awal (keluar awal) Buang kes-kes mudah di awal fungsi menggunakan continue atau return, supaya logik utama kekal di bahagian luar (maksimum ~3 lapis ke dalam). Hasilnya, kod sangat mudah dibaca dari atas ke bawah.
05 Baris 79–149

Model slot — Union dengan tag penyata

Di sini fail tetapan (config) diterjemahkan menjadi senarai tugasan harian yang jelas. Tekniknya: menggabungkan beberapa jenis data dan meletakkan tag supaya sistem tak keliru.

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: iaitu ia hanya membenarkan salah satu daripada 4 nilai teks tersebut — jika tersalah taip, komputer akan terus memberi amaran ralat. EffectiveSlot ialah bentuk objek (medan team? adalah pilihan/boleh tiada). Dalam effectiveSlotsFor, setiap slot.source mempunyai satu medan bernama kind yang berfungsi sebagai 'tag' atau penanda jenis. Teknik ini dipanggil Discriminated union: Selepas komputer membaca src.kind === 'sequence', komputer tahu dengan tepat medan apa yang sepatutnya wujud. Perhatikan bahawa untuk selfPick, kita sengaja tidak menghasilkan slot (keputusan reka bentuk ini dicatatkan pada komen).

Teknik — Gabungan ber-tag ("Jadikan kesalahan suatu yang mustahil") Daripada membiarkan mode: string (yang membenarkan apa sahaja teks ditaip), gunakan union yang menetapkan nilai sah tertentu. Jika anda ada data yang boleh wujud dalam pelbagai bentuk, berikan ia satu medan penanda khas (kind) untuk memudahkan sistem mengawal ralat.
Perkataan baru Union (A | B | C) — Data yang mesti jadi salah satu daripada senarai pilihan. Optional (?) — Medan yang tidak wajib / boleh tiada. Discriminated union — Data jenis union yang diselitkan dengan satu medan khas (seperti tag kind) untuk membezakan jenis. Object.keys — senarai nama bagi setiap kunci objek.
06 Baris 151–206

State — Map & Set untuk kelajuan

Enjin jadual ini perlu ingat data ‘siapa dah bertugas berapa kali’. Pemilihan struktur storan seperti Map & Set di sini sangat penting untuk memastikan sistem tak jadi perlahan.

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 ibarat kamus untuk kita mencari sesuatu dengan sangat pantas; Set pula ibarat senarai semak untuk menjawab soalan "dah ada ke belum?". loadKey menggabungkan dua maklumat (nama slot & nama staf) menjadi satu kunci carian (composite key). Corak penulisan map.set(k, (map.get(k) ?? 0) + n) bermaksud: ‘cuba ambil nilai lama, kalau tak ada, anggap ia 0, kemudian tambahkan dengan nilai baru dan simpan balik’. Kod ini berulang di seluruh fail!

Teknik — Pilih cara simpanan data yang betul Jika nak simpan jumlah atau kiraan → gunakan Map. Jika nak periksa status 'ada/tiada' → gunakan Set. Mengelak daripada mencari satu per satu dalam senarai panjang berulang kali adalah rahsia kod ini berjalan laju.
Perkataan baru Map — Kamus data pantas (kunci → nilai). Set — Senarai semak yang tidak membenarkan data berulang. Composite key — Gabungan beberapa maklumat menjadi satu kunci carian. ?? — Guna nilai sebelah kiri; kalau kosong, baru guna sebelah kanan.
07 Baris 208–335

Kredit cuti/staf baru — Jangan ulang kod sama (DRY)

Ada dua fungsi yang menyelesaikan masalah keadilan dengan cara yang lebih kurang sama. Jadi, daripada menulis kod yang sama dua kali, ia dipisahkan menjadi satu fungsi bantuan. Inilah yang dipanggil prinsip DRY.

Masalah: Algoritma akan memberi tugasan kepada staf yang mempunyai beban paling sedikit. Bayangkan seorang staf yang baru balik dari cuti bersalin panjang (atau staf yang baru lapor diri) — rekod tugasan mereka pasti sifar. Sistem secara automatik akan menimbunkan kerja tanpa henti kepada mereka untuk 'kejar' (catch up) kawan-kawan lain. Ini sangat tidak adil. Penyelesaian: Kita berikan mereka "kredit" percuma menyamai purata markah rakan sekerja pada bulan 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 staf 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 tadi mempunyai struktur yang sama: cari siapa yang tiada pada bulan itu, kirakan purata markah rakan mereka yang hadir, lalu berikan purata markah itu kepada yang tiada tadi. Logik pengiraan ini diletakkan dalam satu fungsi berasingan (monthlyLoads) supaya ia ditulis sekali, tetapi boleh diguna dua kali. Setiap fungsi juga bermula dengan tapisan awal (keluar fungsi kalau tiada kerja nak dibuat).

Teknik — DRY + Nama fungsi yang menjadi dokumen Bila ada dua fungsi berkongsi logik yang serupa, asingkan bahagian yang sama itu menjadi satu fungsi pembantu yang baru (seperti monthlyLoads). Beri nama yang sangat jelas (leaveCredits, newJoinerCredits) berserta komen 'kenapa', supaya orang lain faham tujuannya tanpa perlu membaca keseluruhan kod.
Perkataan baru DRY (Don't Repeat Yourself) — Jangan tulis kod yang sama berulang kali. Helper — Fungsi kecil yang tolong mudahkan kerja fungsi besar. Edge case — Kes terpencil atau luar biasa yang diselesaikan awal.
08 Baris 337–368

Utiliti tarikh & susunan

Lima fungsi kecil sokongan. Dua perkara menarik: pembahagian baki (true modulo) dan susunan data tanpa mengacau senarai 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, pengiraan -1 % 3 akan memberikan -1 (sepatutnya 2) — ini jadi masalah besar bila kita nak kira pusingan jadual ke belakang. Fungsi trueMod membetulkan hal ini supaya sentiasa memberikan nombor positif. Manakala untuk byCycle, ia menunjukkan dua teknik: penggunaan [...pool] untuk 'menyalinkan' senarai staf terlebih dahulu supaya fungsi .sort() tidak mengubah senarai staf asal secara kekal; dan || a.id.localeCompare(b.id) yang berfungsi sebagai pemutus seri yang konsisten (stable tie-break) — jika markah seri, susunan akan sentiasa dipecahkan mengikut ID staf.

Teknik — Jangan ubah data asal; pecah seri dengan konsisten Fungsi seperti .sort() dan .reverse() akan mengubah senarai secara kekal — sentiasa salin senarai itu dahulu dengan [...arr]. Sentiasa berikan pemutus seri yang tetap (seperti ikut nama/ID) supaya komputer tak susun secara rawak setiap kali ia dijalankan.
Perkataan baru Modulo (%) — Mencari baki dari operasi bahagi. Spread ([...arr]) — Cara pantas menyalin keseluruhan senarai. Mutate — Mengubah/merosakkan senarai data asal secara kekal. Stable sort / tie-break — Cara memecahkan keputusan seri dengan konsisten.
09 Baris 370–561

Enjin utama generateMonth (Algoritma pilihan terbaik semasa)

Semua logik tadi berkumpul di sini. Inilah nadi utama fail ini: gelung yang bergerak dari hari ke hari, slot demi slot, untuk memilih calon paling adil — tetapi hanya selepas membuang mereka yang tidak cukup rehat (tapis keselamatan).

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 };
}

Ada tiga keputusan reka bentuk yang menonjol. Keselamatan staf ialah tapisan wajib (hard filter), bukan sekadar markah: Staf yang bercuti atau bertugas semalam akan dibuang terus dari senarai kelayakan awal-awal lagi (lihat 9c, 9e) — keselamatan tak boleh ditukar ganti dengan skor. Kedua, Slot tanpa staf dibiarkan kosong & direkod sebagai konflik untuk pengurus selesaikan secara manual — sistem tidak akan memaksa untuk menyumbat staf yang kepenatan. Ketiga, Nombor rawak hanya digunakan sebagai pemutus seri (rng() * 0.001) — ia takkan mengubah perkiraan keadilan markah utama. Sistem juga meletakkan reason (alasan) pada setiap tugasan yang diberi supaya pengurus tahu kenapa sistem membuat pilihan itu.

Teknik — Tapis syarat wajib dahulu, baru kira markah + "min by score" Asingkan syarat larangan keras (filter) daripada skor keutamaan. Cara mencari calon paling layak (minimum): letak markah awal bestScore = Infinity, buat gelung, ganti setiap kali jumpa markah yang lebih kecil. Akhir sekali, rekod sebab keputusan supaya mudah disemak nanti.
Keputusan reka bentuk yang penting Jika tiada calon yang selamat/rehat cukup, fail ini memilih untuk membiarkan jadual kosong berserta notis amaran berbanding menyumbat nama pekerja secara senyap-senyap. Kod yang bagus akan membuatkan masalah jelas kelihatan, bukan disembunyikan.
Perkataan baru filter — Menapis senarai untuk mengekalkan hanya ahli yang melepasi ujian. Hard vs soft rule — Peraturan mati yang wajib dipatuhi vs peraturan markah yang boleh ditolak-ansur. Min-by-score — Corak pengekodan mencari nilai paling kecil/pemenang. as Staff — Memberitahu komputer: "Saya pasti benda ini bukan null".
10 Baris 563–574

tally — Mengira rekod jumlah

Fungsi terakhir: mengira jumlah tugasan setiap orang untuk dipaparkan di papan pemuka (dashboard). Ini adalah contoh bersih bagaimana memproses senarai panjang menjadi satu ringkasan pendek.

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 pengekodan meringkas senarai (reduce): bermula dengan bekas kosong, masuk ke dalam gelung, dan kumpul jumlah satu per satu. Penggunaan out[k] ??= {...} bermaksud "cipta ruangan kosong jika belum ada" — ia memendekkan tiga baris kod panjang if (!out[k]) out[k] = {...} menjadi satu baris sahaja. Logik utama juga sentiasa bermula dengan tapisan awal (guard): if (!a.staffId) continue.

Teknik — Meringkaskan senarai dengan kemas Meringkaskan senarai panjang menjadi rekod mudah: sedia bekas kosong → buat gelung → kumpul. Simbol ??= membantu menyediakan nilai lalai (default) dalam satu baris pantas.
11 Tamat

Senarai teknik & langkah seterusnya

Satu fail, tapi sangat kaya dengan teknik pengekodan. Inilah rumusan apa yang anda baru pelajari — semuanya berguna untuk dipraktikkan di mana-mana projek.

TeknikDi mana dalam fail
Komen "kenapa" + Catatan tujuan kod di atas fails1 (baris 1–17)
Union & interface ("Jadikan kesilapan itu mustahil")s2 (SlotMode, EffectiveSlot)
Pure function + Penjana rawak tetap (seed RNG)s3 (mulberry32)
Fungsi bersarang (Closure) + tarikh piawai (UTC)s3 (dateKey, daysInMonth)
Tapisan awal untuk keluar cepat (Guard clause)s4 (isUnavailable), s9c, s10
Union ber-tag (Discriminated union mengikut kind)s5 (effectiveSlotsFor)
Storan kelajuan tinggi (Map/Set) + kunci gabungans6 (LoadState, loadKey)
Jangan ulang kod (DRY) + ekstrak fungsi pembantus7 (monthlyLoads)
Bahagi baki yang betul + salin-sebelum-sorts8 (trueMod, byCycle)
Tapis keselamatan wajib dulu, kira skor markah kemudians9 (generateMonth)
Pilih untuk gagal terang-terangan berbanding buat silap senyaps9c/9e
Meringkas data senarai ke dalam rekod menggunakan ??=s10 (tally)
Tema utama seluruh fail Setiap teknik di atas dikumpulkan demi mencapai satu matlamat sahaja: membina kod yang keputusannya boleh diramal, mudah diuji keberkesanannya, dan sentiasa boleh diaudit. Gabungan antara pure functions, tapisan keselamatan wajib yang jelas, dan penulisan alasan bagi setiap satu keputusan yang dibuat komputer — itulah yang membezakan kod yang sekadar "jalan/berfungsi" dengan kod yang benar-benar boleh dipercayai dalam sebuah sistem hospital kritikal.
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?