第三弾改良版です【HTML CSS JS】

年次交番表ローテーションは平日・土曜・日祭日で、通常の休みがなく年間を通して全員が公平に休みを取得して、作業内容をプールしていく仕様です。平日・土曜・日祭日に対応した稼動人数の確保や作業内容の重複など検証が可能です。また、月間の登録者全員の作業内容一覧表や個別カレンダーも表示可能としており、さらに作業数・公休(休み)数・予備数など月間集計や年間集計も実装されています。

年間公休(休み)については、指定した日数を全員が完全に一致して取得することは非常に難しく、実現には事業者側の負担と配慮が必要である。

そのため、公休の不足分については手当等による補填で対応できる一方、問題となるのは公休の超過分である。超過分については「フリー」として扱い、予備補助1名に加え、日々いずれかの交番において必ず休暇取得者が発生することを考慮し、その人員を充当員として活用する。

さらに、従業員が実際にその超過分を休暇として取得したい場合には、事業者都合による公休超過として、有休0.5日分相当で取り扱う。例えば有給休暇が20日の場合は、19.5日として扱う考え方である。

このように、公休の調整は事業者側にとって一定の負担を伴うが、最も重要なのは従業員側の福利厚生を確保することである。

一方、全体のローテーションについては、公平な配置を実現できた。また、公休・予備以外の「泊り・明け・早出・中・1日」の勤務サイクルについては、事業者側で後付け設定できる構成としている。

参考コードについて
下記のコードの使用は、全て修正・追加・削除・名称・UIのデザイン等の変更が可能です。
コードのバグ、動作不具合等は一応確認済みですが、万が一不具合が発生した場合は開発者様で修正をお願いします。

※このページの最下部に実装サンプルを設けていますが、氏名登録の際、サーバーには登録せずクライアント側端末のローカルストレージに保存されますのでご安心ください。

WEB公開について
このページの最下部にMOMO企画の実際のデモサイトをリンクしています。なので、個人使用以外で丸ごとコピペしてWEB上に一般公開する事は許可いたしません。仮にWEB公開する場合は、全面的にUIの変更をお願いします。※コードのコピペはOKです。

HTML(index.html)
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
  >
  <title>年次交番表ローテーション</title>
  <link rel="stylesheet" href="style.css">

  <!-- 日本の祝日判定 -->
  <script src="holidays.js"></script>
</head>

<body>

<header>
  <h1>年次交番表ローテーション Annual Duty Roster Rotation</h1>
</header>

<section id="controls">

  <div class="control-row">

    <label>
      スタート日:
      <input type="date" id="startDate">
    </label>

    <button id="generateBtn">
      交番表を作成
    </button>

  </div>

<div id="nameInput" class="setup-area">

    <!-- 氏名入力 -->
    <div class="person-input-area">

      <textarea
        id="personNames"
        rows="6"
        placeholder="氏名は複数行まとめて入力可能。1行1氏名
        例:
        山田太郎
        佐藤一郎
        鈴木次郎"
      ></textarea>

      <button id="addPersonBtn">
        追加
      </button>

    </div>

  <!-- 交番設定 -->
  <div class="rotation-setting-area">

    <div class="setting-title">
      交番設定(現在設定値)
    </div>

    <div class="setting-row">
      <label for="weekdayKoban">
        平日交番数
      </label>

      <input
        type="number"
        id="weekdayKoban"
        value="63"
        min="1"
      >
    </div>

      <div class="setting-row">
        <label for="saturdayKoban">
          土曜交番数
        </label>

        <input
          type="number"
          id="saturdayKoban"
          value="57"
          min="1"
        >
      </div>

      <div class="setting-row">
        <label for="holidayKoban">
          日祭日交番数
        </label>

        <input
          type="number"
          id="holidayKoban"
          value="49"
          min="1"
        >
      </div>

      <div class="setting-row">
        <label for="reserveCount">
          予備人数
        </label>

        <input
          type="number"
          id="reserveCount"
          value="1"
          min="0"
        >
      </div>

      <div class="setting-row">
        <label for="annualOffDays">
        年間公休数
        </label>

        <input
          type="number"
          id="annualOffDays"
          value="110"
          min="1"
        >
      </div>

      <div class="required-staff-setting">
        必要登録人数:
        <strong id="settingRequiredStaff">64</strong>
        人
      </div>
  </div>

    <div class="general-info-area">
    <div class="general-info-title">説明(概要)</div>
    <ul>
      <li>スタート日設定は現在の日付以降可能</li>
      <li>作業ローテーションはスタート日を基準に1年間</li>
      <li>交番設定は当日の平日・土曜・日祭日・予備の必要人数</li>
      <li>年間公休取得には誤差が出るためローテーション終了前に調整</li>
      <li>重複/不足検証ボタンは当日の交番不足・重複を検証</li>
      <p>公休調整(例)</p>
      <li>休み(公休)の誤差調整は加算年次支給または有休0.5日分で対応</li>

    </ul>

    </div>
</div>

  <div class="info-box">

    <div>
      必要人数:
      <strong id="requiredStaff">-</strong>
      人
    </div>

    <div>
      登録人数:
      <strong id="registeredStaff">0</strong>
      人
    </div>

    <div> 
    現在表示: 
    <strong id="currentPeriod">-</strong>
    <span id="infoMessage" class="info-message"></span>
    </div> 

  </div>

  <div id="personList"></div>

</section>

<section class="toolbar">

  <button id="prevMonth">
    前月
  </button>

  <button id="nextMonth">
    翌月
  </button>

  <button id="showAllBtn">
    重複/不足検証
  </button>

  <button id="printMainBtn">
    一覧印刷(B4横)
  </button>

  <button id="printPersonBtn">
    個別カレンダー印刷(当月・A4横)
  </button>

</section>

<section id="calendarSection">

  <h2 id="mainCalendarTitle">
    年次交番表
  </h2>

  <div
    id="mainCalendar"
    class="calendar-grid"
  ></div>

</section>

<section id="personCalendarSection">

  <h2 id="personCalendarTitle">
    個人カレンダー
    <span id="personCalendarMsg"></span>
  </h2>

  <div
    id="personCalendar"
    class="calendar-grid"
  ></div>

</section>

<section id="dailyAllSection">

  <div id="dailyAllList"></div>

</section>

<section id="auditSection">

  <div
    id="auditResult"
    class="audit-result"
  ></div>

</section>

<script src="app.js"></script>
</body>
</html>
JS(app.js)
// ====年次交番表作成 【ローテーション】=============
const startInput = document.getElementById('startDate');

const today = new Date();
const todayStr =
  `${today.getFullYear()}-` +
  `${String(today.getMonth() + 1).padStart(2, '0')}-` +
  `${String(today.getDate()).padStart(2, '0')}`;

startInput.min = todayStr;
startInput.value = todayStr;

// -----基本設定----------------------
const ROTATION_LIMITS = {
  weekday: 64,
  saturday: 58,
  holiday: 50
};

const rotationIndex = {
  weekday: 0,
  saturday: 0,
  holiday: 0
};

const rotationCounters = {
  weekday: 1,
  saturday: 1,
  holiday: 1
};

// ---画面状態-------------------
const prevBtn = document.getElementById('prevMonth');
const nextBtn = document.getElementById('nextMonth');

let selectedPerson = null;
let currentViewYear = null;
let currentViewMonth = null;

let currentYear = today.getFullYear();
let currentMonth = today.getMonth() + 1;

let isAuditVisible = false;

// ====================氏名管理====================
function getPersonDisplayName(person) {

  const sameNamePersons =
    persons.filter(
      p => p.name === person.name
    );

  /*同名が1人だけなら元の名前をそのまま表示*/
  if (sameNamePersons.length === 1) {
    return person.name;
  }
  /*同名の何番目か*/
  const index =
    sameNamePersons.indexOf(person);

  return `${person.name}(${index + 1})`;
}

const persons = [];

function savePersons() {
  const data = persons.map(p => ({
    id: p.id,
    name: p.name
  }));

  localStorage.setItem('persons', JSON.stringify(data));
}

function loadPersons() {
  const raw = localStorage.getItem('persons');
  if (!raw) return;

  const arr = JSON.parse(raw);

  arr.forEach(p => {
    persons.push({
      id: p.id,
      name: p.name,
      assignments: new Map()
    });
  });
}

function sortPersons() {
  persons.sort((a, b) =>
    a.name.localeCompare(b.name, 'ja')
  );
}

loadPersons();
sortPersons();

// ======日付関係======================
function toLocalDateKey(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');

  return `${y}-${m}-${d}`;
}

function getMonthDates(year, month) {
  const dates = [];
  const days = new Date(year, month, 0).getDate();

  for (let d = 1; d <= days; d++) {
    dates.push(
      toLocalDateKey(
        new Date(year, month - 1, d)
      )
    );
  }

  return dates;
}

function getDayType(date) {
  const dow = date.getDay();

  if (JapaneseHolidays.isHoliday(date)) {
    return 'holiday';
  }

  if (dow === 0) return 'holiday';
  if (dow === 6) return 'saturday';

  return 'weekday';
}

function getMaxKoban(dateKey) {
  const date = new Date(dateKey);
  const type = getDayType(date);

  return ROTATION_LIMITS[type];
}

// ===初期設定=====================
function getRotationSettings() {

  return {
    weekdayKoban:
      Number(
        document.getElementById(
          'weekdayKoban'
        )?.value
      ),

    saturdayKoban:
      Number(
        document.getElementById(
          'saturdayKoban'
        )?.value
      ),

    holidayKoban:
      Number(
        document.getElementById(
          'holidayKoban'
        )?.value
      ),

    reserveCount:
      Number(
        document.getElementById(
          'reserveCount'
        )?.value
      ),

    annualOffDays:
      Number(
        document.getElementById(
          'annualOffDays'
        )?.value
      )
  };
}

function updateRequiredStaffFromSettings() {

  const settings =
    getRotationSettings();

  /*日別最低必要人数*/
  const minimumStaff =
    Math.max(
      settings.weekdayKoban +
        settings.reserveCount,

      settings.saturdayKoban +
        settings.reserveCount,

      settings.holidayKoban +
        settings.reserveCount
    );

  let bestStaff =
    minimumStaff;

  /*開始日から1年間の日数を取得*/
  const startValue =
    startInput.value;

  if (startValue) {

    const startDate =
      new Date(
        startValue + 'T00:00:00'
      );

    if (
      !Number.isNaN(
        startDate.getTime()
      )
    ) {

      const dayCounts = {
        weekday: 0,
        saturday: 0,
        holiday: 0
      };

      const endDate =
        new Date(startDate);

      endDate.setFullYear(
        endDate.getFullYear() + 1
      );

      for (
        let d = new Date(startDate);
        d < endDate;
        d.setDate(
          d.getDate() + 1
        )
      ) {

        const type =
          getDayType(d);

        dayCounts[type]++;
      }

      /*年間総稼働人日*/
      const annualWorkPersonDays =
        dayCounts.weekday *
          (
            settings.weekdayKoban +
            settings.reserveCount
          )
        +
        dayCounts.saturday *
          (
            settings.saturdayKoban +
            settings.reserveCount
          )
        +
        dayCounts.holiday *
          (
            settings.holidayKoban +
            settings.reserveCount
          );

      /*年間公休目標に最も近づく人数を探す*/
      let bestDifference =
        Infinity;

      for (
        let staff = minimumStaff;
        staff <= 500;
        staff++
      ) {

        const averageOff =
          365 -
          (
            annualWorkPersonDays /
            staff
          );

        const difference =
          Math.abs(
            averageOff -
            settings.annualOffDays
          );

        if (
          difference <
          bestDifference
        ) {

          bestDifference =
            difference;

          bestStaff =
            staff;
        }
      }
    }
  }

  /*設定欄の必要登録人数*/
  const settingRequired =
    document.getElementById(
      'settingRequiredStaff'
    );

  if (settingRequired) {
    settingRequired.textContent =
      bestStaff;
  }

  /*上部の必要人数*/
  const requiredStaff =
    document.getElementById(
      'requiredStaff'
    );

  if (requiredStaff) {
    requiredStaff.textContent =
      bestStaff;
  }

  /*登録人数不足メッセージを常に現在の設定と同期*/
  const message =
    document.getElementById(
      'infoMessage'
    );

  if (message) {

    const registered =
      persons.length;

    if (
      registered < bestStaff
    ) {

      const shortage =
        bestStaff - registered;

      message.textContent =
        `現在の設定では${bestStaff}人の登録が必要です。` +
        `あと${shortage}人登録してください。`;

    } else {

      /*必要人数以上になったら不足メッセージを消す*/
      message.textContent = '';
    }
  }

  return bestStaff;
}

[
  'weekdayKoban',
  'saturdayKoban',
  'holidayKoban',
  'reserveCount'
].forEach(id => {

  const input =
    document.getElementById(id);

  if (!input) {
    return;
  }

  input.addEventListener(
    'input',
    updateRequiredStaffFromSettings
  );

  input.addEventListener(
    'change',
    updateRequiredStaffFromSettings
  );
});

function validateRotationSettings(startDate) {

  const settings =
    getRotationSettings();

  const message =
    document.getElementById(
      'infoMessage'
    );

  /*----入力値チェック------------*/
  const values = [
    settings.weekdayKoban,
    settings.saturdayKoban,
    settings.holidayKoban,
    settings.reserveCount,
    settings.annualOffDays
  ];

  if (
    values.some(
      value => !Number.isInteger(value)
    )
  ) {

    if (message) {
      message.textContent =
        '交番設定には整数を入力してください。';
    }

    return false;
  }

  if (
    settings.weekdayKoban < 1 ||
    settings.saturdayKoban < 1 ||
    settings.holidayKoban < 1 ||
    settings.reserveCount < 0 ||
    settings.annualOffDays < 1
  ) {

    if (message) {
      message.textContent =
        '交番設定の値が正しくありません。';
    }

    return false;
  }

  /*-----必要登録人数--------------*/
  const required =
    Math.max(
      settings.weekdayKoban +
        settings.reserveCount,

      settings.saturdayKoban +
        settings.reserveCount,

      settings.holidayKoban +
        settings.reserveCount
    );

  if (
    persons.length < required
  ) {

    const shortage =
      required - persons.length;

    if (message) {
      message.textContent =
        `必要登録人数は${required}人です。` +
        `あと${shortage}人登録してください。`;
    }

    return false;
  }

  /*----------登録人数を超える設定は禁止-----------*/
  if (
    settings.weekdayKoban +
      settings.reserveCount >
      persons.length
  ) {

    if (message) {
      message.textContent =
        '平日交番数+予備人数が登録人数を超えています。';
    }

    return false;
  }

  if (
    settings.saturdayKoban +
      settings.reserveCount >
      persons.length
  ) {

    if (message) {
      message.textContent =
        '土曜交番数+予備人数が登録人数を超えています。';
    }

    return false;
  }

  if (
    settings.holidayKoban +
      settings.reserveCount >
      persons.length
  ) {

    if (message) {
      message.textContent =
        '日祭日交番数+予備人数が登録人数を超えています。';
    }

    return false;
  }

  /*-----1年間の日数を区分別に集計---------*/
  const dayCounts = {
    weekday: 0,
    saturday: 0,
    holiday: 0
  };

  const endDate =
    new Date(startDate);

  endDate.setFullYear(
    endDate.getFullYear() + 1
  );

  for (
    let d = new Date(startDate);
    d < endDate;
    d.setDate(d.getDate() + 1)
  ) {

    const type =
      getDayType(d);

    dayCounts[type]++;
  }

  /*-----1日の公休人数-----------*/
  const weekdayOff =
    persons.length -
    settings.weekdayKoban -
    settings.reserveCount;

  const saturdayOff =
    persons.length -
    settings.saturdayKoban -
    settings.reserveCount;

  const holidayOff =
    persons.length -
    settings.holidayKoban -
    settings.reserveCount;

  if (
    weekdayOff < 0 ||
    saturdayOff < 0 ||
    holidayOff < 0
  ) {

    if (message) {
      message.textContent =
        '現在の設定では公休人数を確保できません。';
    }

    return false;
  }

  /*----------年間公休総数を計算------------*/
  const actualTotalOff =
    dayCounts.weekday *
      weekdayOff
    +
    dayCounts.saturday *
      saturdayOff
    +
    dayCounts.holiday *
      holidayOff;

  const actualAverage =
    actualTotalOff /
    persons.length;

  /*
    -----------------------------
    年間公休数は「目標値」
    
    完全一致を要求しない。
    設定が成立していれば生成可能。
    -----------------------------
  */
  if (message) {

    if (
      Math.abs(
        actualAverage -
        settings.annualOffDays
      ) < 0.01
    ) {

      message.textContent = '';

    } else {

      message.textContent =
        `年間公休目標:${settings.annualOffDays}日 ` +
        `/現在の設定による平均:` +
        `${actualAverage.toFixed(2)}日`;
    }
  }

  return true;
}

function applyRotationSettings() {

  const settings =
    getRotationSettings();

  /*ROTATION_LIMITS は「交番+予備」の総稼働人数として使用*/
  ROTATION_LIMITS.weekday =
    settings.weekdayKoban +
    settings.reserveCount;

  ROTATION_LIMITS.saturday =
    settings.saturdayKoban +
    settings.reserveCount;

  ROTATION_LIMITS.holiday =
    settings.holidayKoban +
    settings.reserveCount;
}

// ==================氏名追加======================
document
  .getElementById('addPersonBtn')
  .addEventListener('click', () => {

    const textarea =
      document.getElementById('personNames');

    const names = textarea.value
      .split('\n')
      .map(s => s.trim())
      .filter(Boolean);

    if (names.length === 0) {

      const message =
        document.getElementById('infoMessage');

      if (message) {
        message.textContent =
          '氏名を入力してください。';
      }

      return;
    }

    names.forEach(name => {

      persons.push({
        id: crypto.randomUUID(),
        name,
        assignments: new Map()
      });

    });

    sortPersons();
    savePersons();
    renderPersonList();

    textarea.value = '';

    /*登録人数を即時反映*/
    const registered =
      document.getElementById(
        'registeredStaff'
      );

    if (registered) {
      registered.textContent =
        persons.length;
    }

    /*現在の登録人数を必要人数にも即時反映※ 現在の85人・87人設定に対応*/
    const required =
      document.getElementById(
        'requiredStaff'
      );

    if (required) {
      required.textContent =
        persons.length;
    }

    /*「氏名が登録されていません」などのメッセージを消す*/
    const message =
      document.getElementById(
        'infoMessage'
      );

    if (message) {
      message.textContent = '';
    }

    /*交番表を再生成*/
    buildRotation();
  });


// =====氏名一覧======================
function renderPersonList() {
  const container =
    document.getElementById('personList');

  container.innerHTML = '';

  persons.forEach(person => {

    const row = document.createElement('div');

    row.style.display = 'flex';
    row.style.alignItems = 'center';
    row.style.gap = '8px';

    const btn = document.createElement('button');

    btn.textContent =
  getPersonDisplayName(person);

btn.style.background =
  '#d8d9ec';

    btn.addEventListener('click', () => {
      selectedPerson = person;

      showPersonCalendar(
        person,
        currentYear,
        currentMonth
      );
    });

    const del = document.createElement('button');

      del.textContent = '削除';
      del.style.background = '#a506a5';
      del.style.color = '#fff4fb';
      del.style.border = 'none';
      del.style.padding = '4px 8px';
      del.style.cursor = 'pointer';

    del.addEventListener('click', () => {
      const index =
        persons.findIndex(p => p.id === person.id);

      if (index === -1) return;

      persons.splice(index, 1);

      savePersons();
      renderPersonList();
      buildRotation();
    });

    row.appendChild(btn);
    row.appendChild(del);

    container.appendChild(row);
  });

  const clearBtn =
    document.createElement('button');

  clearBtn.textContent = '全削除';
  /*clearBtn.style.marginTop = '12px';*/
  clearBtn.style.background = '#b40202';
  clearBtn.style.color = 'white';
  clearBtn.style.padding = '6px 12px';
  clearBtn.style.cursor = 'pointer';

  clearBtn.addEventListener('click', () => {

  const message =
    document.getElementById('infoMessage');

  if (!message) {
    return;
  }

  message.innerHTML = '';

  const text =
    document.createElement('span');

  text.textContent =
    'すべての氏名を削除しますか?';

  message.appendChild(text);

  const yesBtn =
    document.createElement('button');

  yesBtn.type = 'button';
  yesBtn.textContent = 'はい';
  yesBtn.style.marginLeft = '8px';

  yesBtn.addEventListener(
    'click',
    () => {

      persons.length = 0;

      savePersons();
      renderPersonList();

      message.innerHTML = '';
    }
  );

  message.appendChild(yesBtn);

  const noBtn =
    document.createElement('button');

  noBtn.type = 'button';
  noBtn.textContent = 'いいえ';
  noBtn.style.marginLeft = '4px';

  noBtn.addEventListener(
    'click',
    () => {

      message.innerHTML = '';
    }
  );

  message.appendChild(noBtn);

});

  container.appendChild(clearBtn);
}

// ====ローテーション==============
function resetRotation() {
  rotationIndex.weekday = 0;
  rotationIndex.saturday = 0;
  rotationIndex.holiday = 0;

  rotationCounters.weekday = 1;
  rotationCounters.saturday = 1;
  rotationCounters.holiday = 1;
}

function assignDayToPersons(dateKey, dayType) {
  const max = ROTATION_LIMITS[dayType];

  let idx = rotationIndex[dayType] || 0;

  /*その日の稼働者max人のうち1人を予備にする*/
  const settings =
  getRotationSettings();

const reserveCount =
  settings.reserveCount;

const kobanCount =
  max - reserveCount;

for (let n = 1; n <= max; n++) {

  const person =
    persons[idx % persons.length];

  idx++;

  const isReserve =
    n > kobanCount;

  person.assignments.set(
    dateKey,
    {
      type: dayType,
      number: n,
      status:
        isReserve
          ? '予備'
          : '勤務'
    }
  );
}

  rotationIndex[dayType] = idx;

  /*稼働していない人は公休*/
  const assignedSet = new Set();

  persons.forEach(person => {
    const a =
      person.assignments.get(dateKey);

    if (a && a.number !== null) {
      assignedSet.add(person);
    }
  });

  persons.forEach(person => {
    if (!assignedSet.has(person)) {
      person.assignments.set(dateKey, {
        type: 'off',
        number: null,
        status: '公休'
      });
    }
  });
}

// ======日付生成====取り敢えず30年分==============
function generateDates(startDate, years = 30) {

  const list = [];
  const date = new Date(startDate);

  const endDate =
    new Date(
      date.getFullYear() + years,
      date.getMonth(),
      date.getDate()
    );

  while (date < endDate) {

    list.push({
      date: new Date(date),
      type: getDayType(date)
    });

    date.setDate(date.getDate() + 1);
  }

  return list;
}

function buildAssignments(startDate) {

  /*交番連番を開始値に戻す*/
  rotationCounters.weekday = 1;
  rotationCounters.saturday = 1;
  rotationCounters.holiday = 1;

  /*開始日から30年間を生成*/
  const endDate =
    new Date(
      startDate.getFullYear() + 30,
      startDate.getMonth(),
      startDate.getDate()
    );

  let current =
    new Date(
      startDate.getFullYear(),
      startDate.getMonth(),
      startDate.getDate()
    );

  while (current < endDate) {

    const dateKey =
      toLocalDateKey(current);

    const type =
      getDayType(current);

    assignDayToPersons(
      dateKey,
      type
    );

    current.setDate(
      current.getDate() + 1
    );

  }

}

// =====公休====================
function setOffDay(person, dateKey) {

  person.assignments.set(dateKey, {
    type: 'off',
    number: null
  });
}


function assignMonthlyOffDays(year, month) {

  /*
    公休は buildAssignments() の
    assignDayToPersons() で決定済み。

    ここで後からランダムに公休へ変更すると、

    ・交番番号
    ・必要人数
    ・予備者
    ・連勤管理

    が壊れるため、何もしない。
  */
  return;

}

function isPersonOff(
  person,
  dateKey
) {

  const a =
    person.assignments.get(dateKey);

  return Boolean(
    a &&
    a.type === "off"
  );

}

// =====全体生成====================
function checkRegistrationReady() {

  /*現在の設定から必要人数を取得*/
  const required =
    updateRequiredStaffFromSettings();

  const registered =
    persons.length;

  const message =
    document.getElementById(
      'infoMessage'
    );

  /*人数不足*/
  if (registered < required) {

    if (message) {

      const shortage =
        required - registered;

      message.textContent =
        `現在の設定では${required}人の登録が必要です。` +
        `あと${shortage}人登録してください。`;
    }

    return false;
  }

  /*人数が足りたら以前の警告メッセージを消す*/
  if (message) {
    message.textContent = '';
  }

  return true;
}

function buildRotation() {

  /*人数チェック*/
  if (
    !checkRegistrationReady()
  ) {
    return;
  }

  /*開始日*/
  const startDate =
    new Date(
      startInput.value + 'T00:00:00'
    );

  if (
    Number.isNaN(
      startDate.getTime()
    )
  ) {

    const message =
      document.getElementById(
        'infoMessage'
      );

    if (message) {
      message.textContent =
        '開始日が正しくありません。';
    }

    return;
  }

  /*5項目の設定が年間を通して成立するか確認*/
  if (
    !validateRotationSettings(
      startDate
    )
  ) {
    return;
  }

  /*設定値を実際の生成へ反映*/
  applyRotationSettings();

  /*既存割当を完全クリア*/
  persons.forEach(
    person => {

      person.assignments =
        new Map();

    }
  );

  /*30年間生成*/
  buildAssignments(
    startDate
  );
}

// ====交番設定の保存・復元================
const ROTATION_SETTINGS_KEY =
  'rotationSettings';

function saveRotationSettings() {

  const settings = {
    weekdayKoban:
      document.getElementById(
        'weekdayKoban'
      )?.value,

    saturdayKoban:
      document.getElementById(
        'saturdayKoban'
      )?.value,

    holidayKoban:
      document.getElementById(
        'holidayKoban'
      )?.value,

    reserveCount:
      document.getElementById(
        'reserveCount'
      )?.value,

    annualOffDays:
      document.getElementById(
        'annualOffDays'
      )?.value
  };

  localStorage.setItem(
    ROTATION_SETTINGS_KEY,
    JSON.stringify(settings)
  );
}

function loadRotationSettings() {

  const saved =
    localStorage.getItem(
      ROTATION_SETTINGS_KEY
    );

  if (!saved) {
    return;
  }

  try {

    const settings =
      JSON.parse(saved);

    const fields = [
      'weekdayKoban',
      'saturdayKoban',
      'holidayKoban',
      'reserveCount',
      'annualOffDays'
    ];

    fields.forEach(id => {

      const input =
        document.getElementById(id);

      if (
        input &&
        settings[id] !== undefined &&
        settings[id] !== null
      ) {
        input.value =
          settings[id];
      }
    });

  } catch (error) {

    console.error(
      '交番設定の読み込みに失敗しました。',
      error
    );
  }
}

// =======交番設定の保存監視==================
function setupRotationSettingsPersistence() {

  const fields = [
    'weekdayKoban',
    'saturdayKoban',
    'holidayKoban',
    'reserveCount',
    'annualOffDays'
  ];

  fields.forEach(id => {

    const input =
      document.getElementById(id);

    if (!input) {
      return;
    }

    input.addEventListener(
      'input',
      () => {

        saveRotationSettings();

        updateRequiredStaffFromSettings();
      }
    );

    input.addEventListener(
      'change',
      () => {

        saveRotationSettings();

        updateRequiredStaffFromSettings();
      }
    );
  });

  /*初回起動時に保存データを復元*/
  loadRotationSettings();

  /*復元した値で必要人数を再計算*/
  updateRequiredStaffFromSettings();
}

// =====表示用===================
function convertType(type) {

  if (type === 'weekday') return '平日';
  if (type === 'saturday') return '土曜';
  if (type === 'holiday') return '日祭日';

  return type;
}

// =====年次交番表(月間マトリクス)=============
function getAnnualOffNumberMap(person) {

  const result = new Map();

  const start =
    new Date(
      startInput.value + 'T00:00:00'
    );

  if (Number.isNaN(start.getTime())) {
    return result;
  }

  start.setHours(0, 0, 0, 0);

  const end =
    new Date(start);

  end.setFullYear(
    end.getFullYear() + 30
  );

  const settings =
  getRotationSettings();

  const BASE_OFF =
  settings.annualOffDays;

  let yearStart =
    new Date(start);

  while (yearStart < end) {

    const yearEnd =
      new Date(yearStart);

    yearEnd.setFullYear(
      yearEnd.getFullYear() + 1
    );

    if (yearEnd > end) {
      yearEnd.setTime(
        end.getTime()
      );
    }

    /*この年度の実取得公休数*/
    let annualOffCount = 0;

    for (
      let d = new Date(yearStart);
      d < yearEnd;
      d.setDate(
        d.getDate() + 1
      )
    ) {

      const dateKey =
        toLocalDateKey(d);

      const assign =
        person.assignments.get(dateKey);

      if (
        assign &&
        assign.type === 'off'
      ) {
        annualOffCount++;
      }
    }

    if (annualOffCount > 0) {

      let offNumber = 0;

      /*110との差*/
      const difference =
        annualOffCount - BASE_OFF;

      /*不足日数 109 → 1 108 → 2 107 → 3*/
      const shortage =
        BASE_OFF - annualOffCount;

      for (
        let d = new Date(yearStart);
        d < yearEnd;
        d.setDate(
          d.getDate() + 1
        )
      ) {

        const dateKey =
          toLocalDateKey(d);

        const assign =
          person.assignments.get(dateKey);

        if (
          !assign ||
          assign.type !== 'off'
        ) {
          continue;
        }

        offNumber++;

        let label = '';

        /*110日より多い場合 111・112・113…にフリー対象*/
        if (
          difference > 0 &&
          offNumber > BASE_OFF
        ) {
          label = 'フリー対象';
        }

        /*110日より少ない場合 最後の不足日数分に 加算対象*/
        if (
          difference < 0
        ) {

          const firstTarget =
            annualOffCount -
            shortage +
            1;

          if (
            offNumber >= firstTarget &&
            offNumber <= annualOffCount
          ) {
            label = '加算対象';
          }
        }

        result.set(
          dateKey,
          {
            number: offNumber,
            label: label
          }
        );
      }
    }

    yearStart =
      new Date(yearEnd);
  }

  return result;
}

function renderMainCalendar(
  year,
  month
) {

  const container =
    document.getElementById(
      'mainCalendar'
    );

  if (!container) return;

  container.innerHTML = '';

  const days =
    new Date(
      year,
      month,
      0
    ).getDate();

  const table =
    document.createElement('table');

  table.className =
    'rotation-table';

  // ---------ヘッダー-------------
  const thead =
    document.createElement('thead');

  const headRow =
    document.createElement('tr');

  const nameHead =
    document.createElement('th');

  nameHead.className =
    'person-name-head';

  nameHead.textContent =
    '氏名';

  headRow.appendChild(nameHead);

  for (let day = 1; day <= days; day++) {

    const date =
      new Date(
        year,
        month - 1,
        day
      );

    const th =
      document.createElement('th');

    th.className =
      'rotation-date-head';
      
  if (JapaneseHolidays.isHoliday(date)) {

  th.classList.add('holiday-header');

} else if (date.getDay() === 0) {

  th.classList.add('sunday');

} else if (date.getDay() === 6) {

  th.classList.add('saturday');
}

    const dateText =
      document.createElement('div');

    dateText.textContent =
      `${day}日`;

    const dowText =
      document.createElement('div');

    dowText.className =
      'rotation-dow';

    dowText.textContent =
      ['日', '月', '火', '水',
       '木', '金', '土']
      [date.getDay()];

    th.appendChild(dateText);
    th.appendChild(dowText);

    headRow.appendChild(th);
  }

  thead.appendChild(headRow);
  table.appendChild(thead);

  // ------------本体-----------------
  const tbody =
    document.createElement('tbody');

  persons.forEach(person => {

  const annualOffNumbers =
    getAnnualOffNumberMap(person);

  const row =
    document.createElement('tr');

    const nameCell =
  document.createElement('th');

nameCell.className =
  'person-name';

// ----------氏名---------------
const nameText =
  document.createElement('div');

nameText.textContent =
  getPersonDisplayName(person);

nameCell.appendChild(nameText);

// -----スタート日から1年間の公休数------------
let yearlyOff = 0;

// 交番表のスタート日
const rotationStart =
  new Date(startInput.value);

// スタート日の時刻を00:00:00に統一
rotationStart.setHours(
  0, 0, 0, 0
);

// スタート日から1年後
const rotationEnd =
  new Date(rotationStart);

rotationEnd.setFullYear(
  rotationEnd.getFullYear() + 1
);

// その人の全 assignment を確認
for (
  const [dateKey, assign]
  of person.assignments
) {

  if (!assign) continue;

  const date =
    new Date(dateKey);

  date.setHours(
    0, 0, 0, 0
  );

  // スタート日以前は対象外
  if (date < rotationStart) {
    continue;
  }

  // 1年後以降は対象外
  if (date >= rotationEnd) {
    continue;
  }

  // 公休だけカウント
  if (assign.type === 'off') {
    yearlyOff++;
  }
}

const yearlyOffText =
  document.createElement('div');

yearlyOffText.className =
  'yearly-off-count';

yearlyOffText.style.color =
  'blue';

yearlyOffText.textContent =
  `年間公休:${yearlyOff}日`;

nameCell.appendChild(
  yearlyOffText
);

row.appendChild(nameCell);

    for (let day = 1; day <= days; day++) {

      const date =
        new Date(
          year,
          month - 1,
          day
        );

      const dateKey =
        toLocalDateKey(date);

      const cell =
        document.createElement('td');

      cell.className =
        'rotation-cell';

      const assign =
        person.assignments.get(
          dateKey
        );

      if (!assign) {

        cell.textContent = '-';

        row.appendChild(cell);

        continue;
      }

// --------------公休-------------
if (assign.type === 'off') {

  cell.classList.add(
    'rotation-off'
  );

  const offInfo =
    annualOffNumbers.get(
      dateKey
    );

  if (offInfo) {

    /*公休番号*/
    const numberDiv =
      document.createElement('div');

    numberDiv.textContent =
      `公休${offInfo.number}`;

    cell.appendChild(
      numberDiv
    );

    /*フリー対象 / 加算対象*/
    if (offInfo.label) {

      const labelDiv =
        document.createElement('div');

      labelDiv.className =
        'off-adjust-label';

      labelDiv.textContent =
        offInfo.label;

      cell.appendChild(
        labelDiv
      );
    }

  } else {

    cell.textContent =
      '公休';
  }

  row.appendChild(cell);

  continue;
}

      // -----交番---------------
      const type =
        document.createElement('div');

      type.className =
        'rotation-type';

      type.textContent =
        convertType(assign.type);

      cell.appendChild(type);

      if (assign.number != null) {

        const koban =
          document.createElement('div');

        koban.className =
          'rotation-koban';

        koban.textContent =
          `${assign.number}交番`;

        cell.appendChild(koban);
      }

// ----------予備----------
if (assign.status === '予備') {

  // 交番セル全体を予備色にする
  cell.style.backgroundColor = '#d8f5d1';

  const reserve =
    document.createElement('div');

  reserve.className =
    'reserve';

  reserve.textContent =
    '予備';

  cell.appendChild(reserve);
}
      // ---------勤務内容-------------
      if (assign.shift) {

        const shift =
          document.createElement('div');

        shift.className =
          'rotation-shift';

        shift.textContent =
          assign.shift;

        cell.appendChild(shift);
      }

      // ------------予備-------------
      if (
        assign.reserve === true ||
        assign.type === 'reserve'
      ) {

        cell.classList.add(
          'rotation-reserve'
        );

        const reserve =
          document.createElement('div');

        reserve.className =
          'rotation-status';

        reserve.textContent =
          '予備';

        cell.appendChild(reserve);
      }

      row.appendChild(cell);
    }

    tbody.appendChild(row);
  });

  // ------------集計行-----------------
  const frameRow =
    createSummaryRow(
      year,
      month,
      days,
      '交番数',
      'work'
    );

  const offRow =
    createSummaryRow(
      year,
      month,
      days,
      '公休数',
      'off'
    );

  const reserveRow =
    createSummaryRow(
      year,
      month,
      days,
      '予備数',
      'reserve'
    );

  tbody.appendChild(frameRow);
  tbody.appendChild(offRow);
  tbody.appendChild(reserveRow);

  table.appendChild(tbody);

  container.appendChild(table);

  const title =
    document.getElementById(
      'mainCalendarTitle'
    );

  if (title) {

    title.textContent =
      `${year}年${month}月 ` +
      '年次交番表';
  }
}

// ====個人カレンダー================
function renderMonthCalendar(
  container,
  year,
  month,
  person
) {

  container.innerHTML = '';

  const headers = [
    '日', '月', '火', '水',
    '木', '金', '土'
  ];

  headers.forEach((label, i) => {

  const cell =
      document.createElement('div');

    cell.className =
      'day day-header';

    if (i === 0) {
      cell.classList.add('sunday');
    }

    if (i === 6) {
      cell.classList.add('saturday');
    }

    cell.textContent = label;

    container.appendChild(cell);
  });

  const first =
    new Date(year, month - 1, 1);

  const startDow =
    first.getDay();

  const days =
  new Date(year, month, 0).getDate();

  const annualOffNumbers =
  person
    ? getAnnualOffNumberMap(person)
    : new Map();

  for (let i = 0; i < startDow; i++) {

  const cell =
      document.createElement('div');

    cell.className =
      'day empty';

    container.appendChild(cell);
  }

  for (let day = 1; day <= days; day++) {

    const date =
      new Date(year, month - 1, day);

    const dateKey =
      toLocalDateKey(date);

    const cell =
      document.createElement('div');

    cell.className = 'day';

    const holidayInfo =
  JapaneseHolidays.isHoliday(date);

if (holidayInfo) {

    cell.classList.add('national-holiday');

  } else if (date.getDay() === 0) {

    cell.classList.add('holiday');

  } else if (date.getDay() === 6) {

    cell.classList.add('saturday');

  } else {

    cell.classList.add('weekday');
  }

    const title =
      document.createElement('div');

    title.className =
      'date-number';

    title.textContent = day;

    cell.appendChild(title);

    if (person) {

  const assign =
    person.assignments.get(dateKey);

  if (assign) {

    /*公休*/
    if (assign.type === 'off') {

      const offInfo =
        annualOffNumbers.get(dateKey);

      const info =
        document.createElement('div');

      info.className =
        'status off';

      if (offInfo) {

        /*公休番号*/
        info.textContent =
          `公休${offInfo.number}`;

        /*フリー対象 / 加算対象*/
        if (offInfo.label) {

          const label =
            document.createElement('div');

          label.className =
            'off-adjust-label';

          label.textContent =
            offInfo.label;

          info.appendChild(label);
        }

      } else {

        info.textContent =
          '公休';
      }

      cell.appendChild(info);

    } else {

      /*勤務・交番*/
      const info =
        document.createElement('div');

      info.className =
        'koban';

      info.textContent =
        `${convertType(assign.type)}` +
        `${assign.number}交番`;

      cell.appendChild(info);
    }
  }
}

    container.appendChild(cell);
  }
}

// ============年次交番表 集計================
function createSummaryRow(
  year,
  month,
  days,
  label,
  mode
) {

  const row =
    document.createElement('tr');

  row.className =
    'rotation-summary-row';

  const labelCell =
    document.createElement('th');

  labelCell.className =
    'summary-label';

  labelCell.textContent =
    label;

  row.appendChild(labelCell);

  for (let day = 1; day <= days; day++) {

    const date =
      new Date(
        year,
        month - 1,
        day
      );

    const dateKey =
      toLocalDateKey(date);

    let count = 0;

    persons.forEach(person => {

      const assign =
        person.assignments.get(
          dateKey
        );

      if (!assign) return;

      // -------------公休数------------
      if (
        mode === 'off' &&
        assign.type === 'off'
      ) {
        count++;
        return;
      }

      // --------------予備数-----------
      if (
        mode === 'reserve' &&
        (
          assign.status === '予備' ||
          assign.reserve === true ||
          assign.type === 'reserve'
        )
      ) {
        count++;
        return;
      }

      // --------------勤務人数----------
      if (
        mode === 'work' &&
        assign.type !== 'off' &&
        assign.status !== '予備' &&
        assign.type !== 'reserve'
      ) {
        count++;
      }
    });

    const cell =
      document.createElement('td');

    cell.className =
      'rotation-summary-cell';

    cell.textContent =
      count;

    row.appendChild(cell);
  }

  return row;
}

function showPersonCalendar(
  person,
  year,
  month
) {

  currentViewYear = year;
  currentViewMonth = month;

  const [startY, startM] =
    startInput.value.split('-').map(Number);

  const msg =
  document.getElementById(
    'personCalendarMsg'
  );

if (
  year < startY ||
  (year === startY && month < startM)
) {

  msg.textContent =
    '(スタート月以前は表示できません)';

  prevBtn.style.display = 'none';
  nextBtn.style.display = '';

  return;
}

if (
  year === startY &&
  month === startM
) {
  prevBtn.style.display = 'none';
} else {
  prevBtn.style.display = '';
}

nextBtn.style.display = '';

  prevBtn.disabled = false;
  nextBtn.disabled = false;

  const title =
    document.getElementById(
      'personCalendarTitle'
    );

title.textContent =
  `${getPersonDisplayName(person)} のカレンダー ` +
  `${year}年${month}月分`;

  renderMonthCalendar(
    document.getElementById('personCalendar'),
    year,
    month,
    person
  );

  renderMainCalendar(
  year,
  month
);

}

// =====月移動===================
nextBtn.addEventListener('click', () => {

  if (!selectedPerson) return;

  currentMonth++;

  if (currentMonth > 12) {
    currentMonth = 1;
    currentYear++;
  }

  showPersonCalendar(
    selectedPerson,
    currentYear,
    currentMonth
  );
});

prevBtn.addEventListener('click', () => {

  if (!selectedPerson) return;

  currentMonth--;

  if (currentMonth < 1) {
    currentMonth = 12;
    currentYear--;
  }

  showPersonCalendar(
    selectedPerson,
    currentYear,
    currentMonth
  );
});

// ====交番番号 自動検証================
function verifyDay(dateKey) {

  const max =
    getMaxKoban(dateKey);

  const nums = [];

  persons.forEach(person => {

    const a =
      person.assignments.get(dateKey);

    if (!a) return;
    if (a.type === 'off') return;
    if (a.number == null) return;

    nums.push(a.number);
  });

  const set = new Set(nums);

  const duplicate =
    nums.filter(
      (n, i) => nums.indexOf(n) !== i
    );

  const missing = [];

  for (let n = 1; n <= max; n++) {

    if (!set.has(n)) {
      missing.push(n);
    }
  }

  const outOfRange =
    nums.filter(
      n => n < 1 || n > max
    );

  return {
    date: dateKey,
    max,
    count: nums.length,
    duplicate: [...new Set(duplicate)],
    missing,
    outOfRange,

    ok:
      nums.length === max &&
      duplicate.length === 0 &&
      missing.length === 0 &&
      outOfRange.length === 0
  };
}

function verifyMonth(year, month) {

  const dates =
    getMonthDates(year, month);

  const results =
    dates.map(verifyDay);

  return {
    total: results.length,

    ok:
      results.filter(r => r.ok).length,

    ng:
      results.filter(r => !r.ok),

    results
  };
}

// ====検証結果表示================
function showVerification(year, month) {

  const audit =
    document.getElementById('auditResult');

  const result =
    verifyMonth(year, month);

  audit.innerHTML = '';

  const title =
    document.createElement('h3');

  title.textContent =
    `${year}年${month}月 ` +
    `交番番号検証`;

  audit.appendChild(title);

  const summary =
    document.createElement('div');

  summary.textContent =
    `全${result.total}日 / ` +
    `OK ${result.ok}日 / ` +
    `NG ${result.ng.length}日`;

  audit.appendChild(summary);

  result.results.forEach(r => {

    if (r.ok) return;

    const row =
      document.createElement('div');

    row.style.marginTop = '6px';

    row.textContent =
      `${r.date}:` +
      `${r.max}枠 ` +
      `割当${r.count} ` +
      `重複[${r.duplicate.join(',')}] ` +
      `欠番[${r.missing.join(',')}]`;

    audit.appendChild(row);
  });
}

// ======\検証ボタン==================
const showAllBtn =
  document.getElementById('showAllBtn');

showAllBtn.addEventListener('click', () => {

  const audit =
    document.getElementById('auditResult');

  if (!isAuditVisible) {

    showVerification(
      currentViewYear,
      currentViewMonth
    );

    audit.style.display = 'block';
    showAllBtn.textContent = '閉じる';

    isAuditVisible = true;

  } else {

    audit.style.display = 'none';
    showAllBtn.textContent =
      '重複/不足検証';

    isAuditVisible = false;
  }
});

// ==============初期生成==================
function updateStatus() {
  const required =
    document.getElementById('requiredStaffCount');

  const registered =
    document.getElementById('registeredCount');

  const current =
    document.getElementById('currentDisplay');

  if (registered) {
    registered.textContent =
      persons.length;
  }

  if (required) {
    required.textContent =
      persons.length > 0
        ? persons.length
        : '-';
  }

  if (current) {
    current.textContent =
      `${currentYear}年${currentMonth}月`;
  }
}

function updateStatus() {

  const required =
    document.getElementById('requiredStaff');

  const registered =
    document.getElementById('registeredStaff');

  const current =
    document.getElementById('currentPeriod');

  if (registered) {
    registered.textContent =
      persons.length;
  }

  if (required) {
    required.textContent =
      persons.length > 0
        ? persons.length
        : '-';
  }

  if (current) {
    current.textContent =
      `${currentYear}年${currentMonth}月`;
  }
}

setupRotationSettingsPersistence();
updateRequiredStaffFromSettings();
buildRotation();
renderPersonList();
updateStatus();
JS(holidays.js)
// 日本の祝日判定(ブラウザ用・かなり正確な版)
// 対応範囲:2000〜2100年くらいを想定

(function () {
  function ymd(date) {
    return {
      y: date.getFullYear(),
      m: date.getMonth() + 1,
      d: date.getDate(),
      w: date.getDay()
    };
  }

  // 第n月曜日(ハッピーマンデー系)
  function nthMonday(year, month, n) {
    const first = new Date(year, month - 1, 1);
    const firstW = first.getDay();
    const offset = (8 - firstW) % 7; // 最初の月曜までの日数
    return 1 + offset + 7 * (n - 1);
  }

  // 春分の日(簡易近似:1980〜2099年)
  function shunbun(year) {
    if (year <= 1979 || year >= 2100) return 20;
    if (year <= 2099) return Math.floor(20.8431 + 0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4);
    return 20;
  }

  // 秋分の日(簡易近似:1980〜2099年)
  function shubun(year) {
    if (year <= 1979 || year >= 2100) return 23;
    if (year <= 2099) return Math.floor(23.2488 + 0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4);
    return 23;
  }

  // 固定祝日(年月日で判定)
  function fixedHoliday(y, m, d) {
    if (m === 1 && d === 1) return "元日";
    if (m === 2 && d === 11) return "建国記念の日";
    if (m === 4 && d === 29) return "昭和の日";
    if (m === 5 && d === 3) return "憲法記念日";
    if (m === 5 && d === 4) return "みどりの日";
    if (m === 5 && d === 5) return "こどもの日";
    if (m === 8 && d === 11) return "山の日";
    if (m === 11 && d === 3) return "文化の日";
    if (m === 11 && d === 23) return "勤労感謝の日";
    if (y >= 2020 && m === 2 && d === 23) return "天皇誕生日";
    return null;
  }

  // ハッピーマンデー系
  function happyMondayHoliday(y, m, d, w) {
    if (w !== 1) return null;

    if (m === 1 && y >= 2000 && d === nthMonday(y, 1, 2)) return "成人の日";
    if (m === 7 && y >= 2003 && d === nthMonday(y, 7, 3)) return "海の日";
    if (m === 9 && y >= 2003 && d === nthMonday(y, 9, 3)) return "敬老の日";
    if (m === 10 && y >= 2000 && d === nthMonday(y, 10, 2)) return "スポーツの日";

    return null;
  }

  // 春分・秋分
  function equinoxHoliday(y, m, d) {
    const shun = shunbun(y);
    const shu = shubun(y);

    if (m === 3 && d === shun) return "春分の日";
    if (m === 9 && d === shu) return "秋分の日";

    return null;
  }

  // 国民の祝日(基本判定)
  function baseNationalHoliday(date) {
    const { y, m, d, w } = ymd(date);

    const fixed = fixedHoliday(y, m, d);
    if (fixed) return fixed;

    const happy = happyMondayHoliday(y, m, d, w);
    if (happy) return happy;

    const eq = equinoxHoliday(y, m, d);
    if (eq) return eq;

    return null;
  }

  // 振替休日
  function isSubstituteHoliday(date) {
    const { y, m, d, w } = ymd(date);
    if (w === 0) return null;

    const prev = new Date(y, m - 1, d - 1);
    const prevName = baseNationalHoliday(prev);
    const prevW = prev.getDay();

    if (prevName && prevW === 0) {
      return "振替休日";
    }
    return null;
  }

  // 国民の休日(祝日に挟まれた平日)
  function isCitizenHoliday(date) {
    const { y, m, d, w } = ymd(date);
    if (w === 0 || w === 6) return null;

    const prev = new Date(y, m - 1, d - 1);
    const next = new Date(y, m - 1, d + 1);

    const prevName = baseNationalHoliday(prev);
    const nextName = baseNationalHoliday(next);

    if (prevName && nextName) {
      return "国民の休日";
    }
    return null;
  }

  window.JapaneseHolidays = {
    isHoliday: function (date) {
      const base = baseNationalHoliday(date);
      if (base) return { name: base };

      const sub = isSubstituteHoliday(date);
      if (sub) return { name: sub };

      const citizen = isCitizenHoliday(date);
      if (citizen) return { name: citizen };

      return null;
    }
  };
})();
CSS(style.css)
/* =======年次交番表ローテーション======== */
* {
  box-sizing: border-box;
}

body {
  font-family:
    "Yu Gothic",
    "Hiragino Kaku Gothic ProN",
    Meiryo,
    sans-serif;

  padding:
    5px
    20px
    20px
    20px;

  margin: 0;

  background: #c9caca;

  color: #222;
}

/* ------------見出し------------------ */
h1 {
  font-size: 24px;
  color: #1565c0;
  margin: 5px 0 15px;
}

h2 {
  font-size: 18px;
  margin: 15px 0 8px;
}

/* ------------コントロール-------------- */
#controls {
  background: #e5f5db;
  border: 1px solid #cfd8dc;
  border-radius: 6px;
  padding: 12px;
  margin-bottom: 10px;
}

.control-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 10px;
}

label {
  font-size: 16px;
}

input[type="date"] {
  font-size: 15px;
  padding: 5px 8px;
}

textarea {
  width: 100%;
  max-width: 260px;
  margin-top: 2px;
  font-size: 15px;
  line-height: 2.56;
  background: #e8f0f3;
  padding: 8px;

  border:
    1px solid
    #a978f8;

  border-radius: 4px;

  resize: vertical;
}

button {
  border: 1px solid #90a4ae;

  background: #ffffff;

  border-radius: 4px;

  padding: 7px 13px;

  font-size: 14px;

  cursor: pointer;
}

button:hover {
  background: #eef6fb;
}

button:active {
  transform: translateY(1px);
}

#generateBtn {
  background: #1976d2;
  color: white;
  border-color: #1976d2;
}

#generateBtn:hover {
  background: #1565c0;
}

#addPersonBtn {
  margin-top: 6px;
  background: #225704;
  color: white;
  border-color: #022503;
}

/* -----------------情報------------------- */
.info-box {
  display: flex;
  flex-wrap: wrap;
  gap: 18px;

  margin-top: 10px;

  padding: 8px;

  background: #f5f9fc;

  border: 1px solid #d7e1e7;

  border-radius: 4px;
}

.info-box strong {
  color: #1565c0;
}

/* ---------------人員一覧-------------------- */
#personList {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;

  margin-top: 10px;
}

.person-button {
  background: #ffffff;

  border:
    1px solid
    #90a4ae;

  padding: 5px 9px;

  border-radius: 4px;

  cursor: pointer;
}

.person-button.active {
  background: #1976d2;
  color: #ffffff;
  border-color: #1976d2;
}

/* ---------------ツールバー----------------- */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;

  margin: 10px 0;
}

/* -----------------カレンダー--------------- */
.calendar-grid {
  display: grid;

  grid-template-columns:
    repeat(7, minmax(0, 1fr));

  gap: 4px;

  margin-top: -2px;
}

.day {
  min-height: 125px;

  border:
    1px solid
    #ccc;

  background: #ffffff;

  font-size: 18px;

  padding: 5px;

  overflow: hidden;
}

.day-header {
  min-height: auto;

  font-weight: bold;

  text-align: center;

  padding: 5px;

  background: #eef3f6;

  border:
    1px solid
    #cbd5da;
}
.national-holiday {
  background: #ffcccc;
}
.day-header.sunday {
  color: #d32f2f;
}

.day-header.saturday {
  color: #1565c0;
}

.day.empty {
  background: #f0f0f0;
}

.rotation-date-head.sunday,
.rotation-date-head.holiday-header {
  color: #d32f2f;
}

.rotation-date-head.saturday {
  color: #1565c0;
}

/* --------------日付タイプ--------------------- */
.day.weekday {
  background: #ffffff;
}

.day.saturday {
  background: #ddeeff;
}

.day.holiday {
  background: #ffdddd;
}

/* ----------------個人状態----------------- */
.day.yobi {
  background: #d8f5d1;
}

.day.off {
  background: #ffe8aa;
}

.day.work {
  background: #ffffff;
}

/* ------------カレンダー内部---------------- */
.date-number {
  font-weight: bold;

  font-size: 13px;

  margin-bottom: 3px;
}

.holiday-name {
  color: #c62828;

  font-size: 11px;

  margin-bottom: 2px;
}

.shift {
  font-weight: bold;

  margin-top: 4px;
}

.koban {
  font-size: 16px;

  font-weight: bold;

  color: #0d47a1;

  margin-top: 3px;
}

.job {
  margin-top: 3px;

  line-height: 1.35;
}

.status {
  display: inline-block;

  margin-top: 4px;

  padding: 2px 5px;

  border-radius: 3px;

  font-weight: bold;
}

.status.off {
  background: #ffd54f;
}

.status.reserve {
  background: #a5d6a7;
}

.status.work {
  background: #bbdefb;
}

/* -------------日別一覧-------------------- */
#dailyAllList {
  margin-top: 20px;
}

.daily-card {
  background: #ffffff;

  border:
    1px solid
    #cfd8dc;

  border-radius: 5px;

  margin-bottom: 12px;

  padding: 10px;
}

.daily-title {
  font-weight: bold;

  font-size: 16px;

  margin-bottom: 8px;
}

.daily-table {
  width: 100%;

  border-collapse: collapse;

  font-size: 12px;
}

.daily-table th,
.daily-table td {
  border:
    1px solid
    #d0d7dc;

  padding: 4px 6px;

  text-align: left;
}

.daily-table th {
  background: #eef3f6;
}

/* --------------検証結果--------------------- */
.audit-result {
  margin-top: 20px;

  background: #ffffff;

  border:
    1px solid
    #cfd8dc;

  border-radius: 5px;

  padding: 10px;
}

.audit-ok {
  color: #2e7d32;

  font-weight: bold;
}

.audit-ng {
  color: #c62828;

  font-weight: bold;
}

.audit-warning {
  color: #ef6c00;

  font-weight: bold;
}

.audit-line {
  margin: 4px 0;
}

/* ===========年次交番表月間マトリクス=========== */
#mainCalendar {
  width: 100%;
  overflow-x: auto;
  margin-top: 8px;
}

.rotation-table {
  border-collapse: collapse;
  table-layout: fixed;
  min-width: max-content;
  font-size: 11px;
}

.rotation-table th,
.rotation-table td {
  border: 1px solid #999;
  box-sizing: border-box;
}

.person-name-head {
  position: sticky;
  left: 0;
  z-index: 3;

  width: 70px;
  min-width: 70px;

  padding: 5px;

  background: #eef3f6;
}

.rotation-date-head {
  width: 50px;
  min-width: 50px;

  padding: 4px;

  text-align: center;

  background: #eef3f6;
}

.rotation-date-head.sunday {
  color: #d32f2f;
}

.rotation-date-head.saturday {
  color: #1565c0;
}

.rotation-dow {
  font-size: 10px;
  margin-top: 2px;
}

.person-name {
  position: sticky;
  left: 0;
  z-index: 2;

  width: 70px;
  min-width: 70px;

  padding: 5px;

  text-align: left;

  background: #ffffff;

  font-weight: bold;
}

.rotation-cell {
  width: 50px;
  min-width: 50px;

  height: 58px;

  padding: 3px;

  text-align: center;

  vertical-align: middle;

  background: #ffffff;
}

.rotation-type {
  font-size: 10px;
}

.rotation-koban {
  font-size: 13px;
  font-weight: bold;

  color: #0d47a1;

  margin-top: 2px;
}

.rotation-shift {
  font-weight: bold;
  margin-top: 2px;
}

.rotation-status {
  margin-top: 2px;
  font-weight: bold;
}

.rotation-off {
  background: #ffe8aa;
  font-weight: bold;
}

.rotation-reserve {
  background: #d8f5d1;
}

.rotation-summary-row th,
.rotation-summary-row td {
  height: 30px;
  padding: 4px;
  text-align: center;
  font-weight: bold;
}

.summary-label {
  position: sticky;
  left: 0;
  z-index: 2;

  background: #eef3f6;

  text-align: left;
}

.rotation-summary-cell {
  background: #f7f7f7;
}

.info-message {
  margin-left: 12px;
  color: #d32f2f;
  font-weight: bold;
}

#nameInput.setup-area {
  display: flex;
  align-items: flex-start;
  gap: 24px;
}

.person-input-area {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}

.person-input-area textarea {
  width: 320px;
  resize: vertical;
}

.person-input-area button {
  height: 40px;
  padding: 6px 18px;
}

.rotation-setting-area {
  min-width: 270px;
  padding: 14px;
  border: 1px solid #ccc;
  border-radius: 8px;
  background: #fff;
}

.setting-title {
  margin-bottom: 12px;
  font-weight: bold;
  font-size: 16px;
}

.setting-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 9px;
}

.setting-row label {
  margin-right: 15px;
}

.setting-row input {
  width: 70px;
  padding: 4px 6px;
  text-align: right;
}

.required-staff-setting {
  margin-top: 12px;
  padding-top: 10px;
  border-top: 1px solid #ddd;
  font-weight: bold;
}

/* =============印刷============= */
@media print {

  /*------B4 横:一覧表------------*/
  @page mainPage {
    size: B4 landscape;
    margin: 7mm;
  }

  /*------A4 横:個人カレンダー-----*/
  @page personPage {
    size: A4 landscape;
    margin: 8mm;
  }

  /*--------一覧印刷----------*/
  body.print-main {
    page: mainPage;
  }

  body.print-main > * {
    visibility: hidden !important;
  }

  body.print-main #mainCalendarTitle,
  body.print-main #mainCalendarTitle *,
  body.print-main #mainCalendar,
  body.print-main #mainCalendar * {
    visibility: visible !important;
  }

  body.print-main #mainCalendarTitle {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    margin: 0 0 5mm 0;
  }

  body.print-main #mainCalendar {
    position: absolute;
    left: 0;
    top: 12mm;
    width: 100%;
    height: auto;
    max-height: none;
    overflow: visible !important;
  }

  body.print-main #mainCalendar table {
    width: 100%;
    table-layout: fixed;
    border-collapse: collapse;
  }

  body.print-main #mainCalendar th,
  body.print-main #mainCalendar td {
    padding: 1.5mm 1mm;
    font-size: 7pt;
    line-height: 1.15;
  }

  /*-----------個人カレンダー----------*/
  body.print-person {
    page: personPage;
  }

  body.print-person > * {
    visibility: hidden !important;
  }

  body.print-person #personCalendarTitle,
  body.print-person #personCalendarTitle *,
  body.print-person #personCalendar,
  body.print-person #personCalendar * {
    visibility: visible !important;
  }

  body.print-person #personCalendarTitle {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    margin: 0 0 5mm 0;
  }

  body.print-person #personCalendar {
    position: absolute;
    left: 0;
    top: 15mm;
    width: 100%;
    height: auto;
    max-height: none;
    overflow: visible !important;
  }

  body.print-person #personCalendar {
    display: grid;
  }

  body.print-person #personCalendar .day {
    font-size: 10pt;
  }

  /*印刷時の不要表示*/
  body.print-main .toolbar,
  body.print-main #nameInput,
  body.print-main .info-box,

  body.print-person .toolbar,
  body.print-person #nameInput,
  body.print-person .info-box {
    display: none !important;
  }

}

.general-info-area {
  width: 600px;
  padding: 12px 14px;
  border: 1px solid #ccc;
  border-radius: 8px;
  background: #e1ebb8;
}

.general-info-title {
  margin-bottom: 8px;
  font-weight: bold;
}

.general-info-area ul {
  margin: 0;
  padding-left: 20px;
}

.general-info-area li {
  margin-bottom: 4px;
}
p{color: #1565c0;font-weight: bold;}

/* ------------レスポンシブ------------- */
@media (max-width: 900px) {

  .calendar-grid {
    gap: 2px;
  }

  .day {
    min-height: 110px;
    padding: 3px;
    font-size: 11px;
  }

  .koban {
    font-size: 12px;
  }

}

@media (max-width: 650px) {

  body {
    padding-left: 6px;
    padding-right: 6px;
  }

  .calendar-grid {
    grid-template-columns:
      repeat(7, minmax(80px, 1fr));

    overflow-x: auto;
  }

  .day,
  .day-header {
    min-width: 80px;
  }

  .control-row {
    align-items: flex-start;
    flex-direction: column;
  }
}

≪≪実装サンプル 年次交番表ローテーション≫≫