第7回 試験用キッチンタイマー制作|時間方式・終了表示編

この記事は約94分で読めます。

第6回では、マシントラブルなどが発生した受験者向けに、個別延長タイマーを追加できるようにしました。

今回は、試験の進め方に合わせて「累積時間」と「個別時間」を段ごとに切り替えられるようにし、さらに上段・下段・通常試験全体の終了状態を、画面を見ただけで分かるようにしていきます。

今回も、必要な機能や画面の動きはきょろぽんが考え、HTML・CSS・JavaScriptのソースコード作成はAIのノンが担当しています。完成したコードを実際に動かし、見た目や使い勝手を確認しながら修正しました。

今回追加する機能

第7回で追加したのは、主に次の機能です。

  • 上段・下段ごとに「累積時間」「個別時間」を選択できるようにする
  • 時間方式はラジオボタンで選択し、初期値は「累積時間」にする
  • 上段の通常タイマーがすべて終了したら、上段全体を回転して終了表示へ切り替える
  • 下段の通常タイマーがすべて終了したら、下段全体を回転して終了表示へ切り替える
  • 上段・下段の通常タイマーがすべて終了した1秒後に、画面中央へ全体終了を表示する
  • 全体終了は約1.5秒かけてフェードインする
  • 個別延長タイマーは通常試験の終了判定とは分け、そのまま動作を続ける
  • 個別延長の追加時に「増やす」「減らす」を選択できるようにする
  • 残り時間を超えて減らそうとした場合はエラーを表示する

通常の試験を受けている人は、上段・下段の終了を確認したら静かに退出できます。一方、トラブル対応で個別延長が必要な人は、そのまま個別延長タイマーを見ながら試験を続ける想定です。

完成したソースコード

今回も、まず完成したソースコードをまとめて掲載します。前回の第6回版からコピーして、第7回用のフォルダで編集しています。

第7回のフォルダには、これまでと同じく index.html、style.css、script.js の3ファイルを置きます。前回版を上書きせず、各回の完成状態を残しておきます。

index.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">
</head>
<body>
  <main class="app-shell">
    <section id="setup-screen" class="screen setup-screen" aria-labelledby="setup-title">
      <header class="page-header">
        <h1 id="setup-title">試験用キッチンタイマー</h1>
        <p>上段・下段のタイマーを設定してください</p>
      </header>

      <form id="setup-form" class="setup-form" novalidate>
        <section class="setup-group board-title-setup" aria-labelledby="board-title-setup-heading">
          <h2 id="board-title-setup-heading" class="setup-subheading">画面タイトル</h2>
          <div class="field-block">
            <label for="board-title-input">一番上に表示するタイトル</label>
            <input id="board-title-input" name="boardTitle" type="text" value="試験用キッチンタイマー" maxlength="40">
          </div>
        </section>

        <section class="setup-group" aria-labelledby="upper-setup-heading">
          <div class="setup-group-header">
            <div class="field-block title-field-block">
              <label for="upper-title">上段タイトル</label>
              <input id="upper-title" name="upperTitle" type="text" value="上段タイトル" maxlength="30">
            </div>
            <div class="field-block count-field-block">
              <label for="upper-count">タイマー数</label>
              <select id="upper-count" name="upperCount">
                <option value="1">1個</option><option value="2">2個</option><option value="3">3個</option><option value="4" selected>4個</option>
              </select>
              <p class="count-note">上段に、一番タイマー数の多い段を設定してください。</p>
            </div>
          </div>
          <fieldset class="time-mode-fieldset">
            <legend>時間の表示方法</legend>
            <label class="radio-option"><input type="radio" name="upper-time-mode" value="cumulative" checked> 累積時間</label>
            <label class="radio-option"><input type="radio" name="upper-time-mode" value="individual"> 個別時間</label>
          </fieldset>
          <h2 id="upper-setup-heading" class="visually-hidden">上段タイマー設定</h2>
          <div id="upper-timer-settings" class="timer-settings-list"></div>
        </section>

        <section class="setup-group" aria-labelledby="lower-setup-heading">
          <div class="setup-group-header">
            <div class="field-block title-field-block">
              <label for="lower-title">下段タイトル</label>
              <input id="lower-title" name="lowerTitle" type="text" value="下段タイトル" maxlength="30">
            </div>
            <div class="field-block count-field-block">
              <label for="lower-count">タイマー数</label>
              <select id="lower-count" name="lowerCount">
                <option value="0">0個</option><option value="1">1個</option><option value="2">2個</option><option value="3" selected>3個</option><option value="4">4個</option>
              </select>
              <p class="count-note">下段は、上段のタイマー数以下から選択できます。</p>
            </div>
          </div>
          <fieldset class="time-mode-fieldset">
            <legend>時間の表示方法</legend>
            <label class="radio-option"><input type="radio" name="lower-time-mode" value="cumulative" checked> 累積時間</label>
            <label class="radio-option"><input type="radio" name="lower-time-mode" value="individual"> 個別時間</label>
          </fieldset>
          <h2 id="lower-setup-heading" class="visually-hidden">下段タイマー設定</h2>
          <div id="lower-timer-settings" class="timer-settings-list"></div>
        </section>

        <p id="setup-error" class="setup-error" aria-live="polite"></p>
        <div class="setup-actions">
          <button class="primary-button create-board-button" type="submit">タイマーボードを作成</button>
        </div>
      </form>
    </section>

    <section id="board-screen" class="screen board-screen" aria-labelledby="board-title" hidden>
      <header class="page-header board-header">
        <h1 id="board-title">試験用キッチンタイマー</h1>
      </header>

      <section id="upper-timer-section" class="timer-section timer-section-upper" aria-labelledby="upper-heading">
        <div id="upper-group-flip" class="group-flip-card">
          <div class="group-face group-face-front">
            <div class="section-heading"><h2 id="upper-heading">上段タイトル</h2></div>
            <div id="upper-timer-grid" class="timer-grid"></div>
          </div>
          <div class="group-face group-face-back" aria-live="polite">
            <p id="upper-finished-title" class="group-finished-title">上段タイトル</p>
            <p class="group-finished-text">終了</p>
          </div>
        </div>
      </section>

      <section id="lower-timer-section" class="timer-section timer-section-lower" aria-labelledby="lower-heading">
        <div id="lower-group-flip" class="group-flip-card">
          <div class="group-face group-face-front">
            <div class="section-heading"><h2 id="lower-heading">下段タイトル</h2></div>
            <div id="lower-timer-grid" class="timer-grid"></div>
          </div>
          <div class="group-face group-face-back" aria-live="polite">
            <p id="lower-finished-title" class="group-finished-title">下段タイトル</p>
            <p class="group-finished-text">終了</p>
          </div>
        </div>
      </section>

      <section id="extension-timer-section" class="timer-section timer-section-extension" aria-labelledby="extension-heading" hidden>
        <div class="section-heading"><h2 id="extension-heading">個別延長</h2></div>
        <div id="extension-timer-grid" class="timer-grid"></div>
      </section>

      <div id="board-finish-overlay" class="board-finish-overlay" aria-live="polite" aria-hidden="true">
        <div class="board-finish-message">
          <p id="board-finished-title" class="board-finished-title">試験用キッチンタイマー</p>
          <p class="board-finished-text">終了</p>
        </div>
      </div>

      <div id="floating-controls" class="floating-controls is-visible" aria-label="操作パネル">
        <button id="start-all-button" class="primary-button control-button" type="button">▶ 全体スタート</button>
        <button id="reset-all-button" class="secondary-button control-button" type="button">↺ 全体リセット</button>
        <button id="add-extension-button" class="secondary-button control-button" type="button" disabled>+ 個別延長</button>
        <button id="finish-button" class="secondary-button control-button finish-button" type="button">終了</button>
      </div>
    </section>
  </main>

  <dialog id="extension-dialog" class="extension-dialog">
    <form id="extension-form" class="extension-form" method="dialog" novalidate>
      <h2 id="extension-dialog-title">個別延長タイマーを作成</h2>

      <div id="extension-name-field" class="field-block">
        <label for="extension-name">表示名</label>
        <input id="extension-name" type="text" maxlength="30" placeholder="受験者名・座席番号など">
      </div>

      <div id="extension-base-field" class="field-block">
        <label for="extension-base-group">基準にする段</label>
        <select id="extension-base-group"></select>
      </div>

      <div id="extension-current-total" class="extension-current-total" hidden></div>

      <fieldset id="extension-adjust-fieldset" class="extension-adjust-fieldset" hidden>
        <legend>時間の調整</legend>
        <label class="radio-option"><input type="radio" name="extension-adjust-mode" value="increase" checked> 増やす</label>
        <label class="radio-option"><input type="radio" name="extension-adjust-mode" value="decrease"> 減らす</label>
      </fieldset>

      <div class="extension-duration-row">
        <div class="field-block compact-field-block">
          <label for="extension-minutes">追加する分</label>
          <input id="extension-minutes" type="number" min="0" step="1" value="0" inputmode="numeric">
        </div>
        <div class="field-block compact-field-block">
          <label for="extension-seconds">追加する秒</label>
          <input id="extension-seconds" type="number" min="0" max="59" step="1" value="0" inputmode="numeric">
        </div>
      </div>

      <p id="extension-error" class="setup-error" aria-live="polite"></p>
      <div class="dialog-actions">
        <button id="extension-cancel-button" class="secondary-button" type="button">キャンセル</button>
        <button id="extension-submit-button" class="primary-button" type="submit">作成</button>
      </div>
    </form>
  </dialog>

  <template id="setting-row-template">
    <div class="timer-setting-row">
      <div class="field-block name-field-block">
        <label class="timer-name-label"></label>
        <input class="timer-name-input" type="text" maxlength="30">
      </div>
      <div class="duration-fields">
        <div class="field-block compact-field-block"><label>分</label><input class="minutes-input" type="number" min="0" step="1" inputmode="numeric"></div>
        <div class="field-block compact-field-block"><label>秒</label><input class="seconds-input" type="number" min="0" max="59" step="1" inputmode="numeric"></div>
      </div>
    </div>
  </template>

  <template id="timer-panel-template">
    <article class="timer-panel">
      <div class="timer-panel-inner">
        <div class="timer-face timer-face-front">
          <p class="timer-title"></p>
          <div class="time-display" aria-label="残り時間">
            <span class="minutes-display">00</span><span class="time-colon" aria-hidden="true">:</span><span class="seconds-display">00</span>
          </div>
          <div class="extension-info" hidden>
            <p class="extension-history"></p>
            <p class="extension-total"></p>
          </div>
          <div class="extension-panel-actions interaction-controls" hidden>
            <button class="secondary-button extension-add-button" type="button">+ 追加</button>
            <button class="secondary-button extension-delete-button" type="button">削除</button>
          </div>
        </div>
        <div class="timer-face timer-face-back" aria-live="polite">
          <div class="finished-message">
            <p class="finished-title"></p>
            <p class="finished-text">終了</p>
          </div>
          <div class="extension-panel-actions interaction-controls finished-actions" hidden>
            <button class="secondary-button extension-add-button" type="button">+ 追加</button>
            <button class="secondary-button extension-delete-button" type="button">削除</button>
          </div>
        </div>
      </div>
    </article>
  </template>

  <script src="script.js"></script>
</body>
</html>

style.css

style.css のソースコードを表示
* { box-sizing: border-box; }

:root {
  color-scheme: dark;
  font-family: "Segoe UI", "Yu Gothic UI", "Meiryo", sans-serif;
}

html, body { margin: 0; min-width: 320px; min-height: 100%; }
body {
  min-height: 100vh;
  background: radial-gradient(circle at center, #17243a 0%, #070b12 60%, #000 100%);
  color: #f7f9ff;
  overflow-x: hidden;
}
button, input, select { font: inherit; -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
input, select {
  width: 100%; min-height: 48px; border: 1px solid rgba(139,183,245,.5); border-radius: 12px;
  background: rgba(8,17,31,.9); color: #f7f9ff; outline: none;
}
input { padding: 10px 12px; }
select { padding: 10px 38px 10px 12px; }
input:focus, select:focus { border-color:#8fbaff; box-shadow:0 0 0 3px rgba(93,147,255,.2); }

.app-shell { width:min(1920px,100%); min-height:100vh; margin:0 auto; padding:clamp(14px,2vw,32px); }
.screen[hidden], .timer-section[hidden], .timer-settings-list[hidden], [hidden] { display:none !important; }

.page-header { margin-bottom:clamp(18px,2vw,30px); text-align:center; }
.page-header h1, .page-header p, .section-heading h2 { margin:0; }
.page-header h1 { font-size:clamp(1.8rem,3vw,3.1rem); }
.page-header p { margin-top:7px; color:#aebfd8; font-size:clamp(.9rem,1.2vw,1.15rem); }
.board-header { margin-bottom:clamp(10px,1.2vw,18px); }

.setup-form { display:grid; gap:clamp(20px,2.4vw,34px); }
.setup-group { padding:clamp(16px,2vw,28px); border:1px solid rgba(151,190,245,.35); border-radius:22px; background:linear-gradient(145deg,rgba(23,39,63,.88),rgba(7,13,24,.93)); box-shadow:0 0 28px rgba(85,139,226,.12); }
.setup-group-header { display:grid; grid-template-columns:minmax(0,1fr) minmax(140px,200px); gap:18px; margin-bottom:18px; }
.field-block { display:grid; gap:7px; }
.field-block label { color:#dbe8fb; font-weight:700; }
.count-note { margin:2px 0 0; color:#9fb2ce; font-size:.78rem; line-height:1.35; }
.timer-settings-list { display:grid; gap:12px; }
.timer-setting-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:16px; padding:14px; border:1px solid rgba(131,169,224,.22); border-radius:16px; background:rgba(5,12,23,.48); }
.duration-fields, .extension-duration-row { display:grid; grid-template-columns:repeat(2,minmax(90px,110px)); gap:10px; }
.setup-error { min-height:1.5em; margin:-2px 0 0; color:#ff9b9b; font-weight:700; text-align:center; }
.setup-actions { display:flex; justify-content:center; padding-bottom:20px; }
.setup-subheading { margin:0 0 14px; font-size:clamp(1.2rem,1.8vw,1.6rem); }
.board-title-setup { background:linear-gradient(145deg,rgba(22,36,58,.9),rgba(7,13,24,.94)); }

.primary-button, .secondary-button { min-height:48px; padding:10px 20px; border:1px solid #84b6ff; border-radius:14px; color:#eef5ff; font-weight:700; }
.primary-button { background:linear-gradient(145deg,#376cff,#1541b8); box-shadow:0 0 18px rgba(71,126,255,.35), inset 0 0 10px rgba(255,255,255,.1); }
.secondary-button { border-color:rgba(151,184,227,.58); background:rgba(21,34,54,.95); box-shadow:inset 0 0 10px rgba(122,169,236,.08); }
.primary-button:hover:not(:disabled), .primary-button:focus-visible:not(:disabled), .secondary-button:hover:not(:disabled), .secondary-button:focus-visible:not(:disabled) { filter:brightness(1.14); outline:3px solid rgba(155,198,255,.32); outline-offset:3px; }
button:disabled { cursor:not-allowed; opacity:.35; }
.create-board-button { width:min(440px,100%); min-height:56px; font-size:clamp(1.05rem,1.7vw,1.45rem); }

.timer-section { margin-bottom:clamp(12px,1.4vw,22px); padding:clamp(10px,1vw,18px); border:1px solid rgba(166,198,241,.18); border-radius:clamp(16px,1.5vw,24px); }
.timer-section-upper { background:linear-gradient(145deg,rgba(38,88,150,.14),rgba(7,13,24,.38)); }
.timer-section-lower { background:linear-gradient(145deg,rgba(92,57,137,.14),rgba(7,13,24,.38)); }
.timer-section-extension { background:linear-gradient(145deg,rgba(170,117,28,.18),rgba(30,21,6,.46)); border-color:rgba(239,188,87,.24); }

.extension-timer-panel.extension-base-upper .timer-face {
  background:linear-gradient(145deg,rgba(35,70,118,.96),rgba(8,18,34,.97));
  border-color:rgba(132,181,247,.62);
  box-shadow:0 0 24px rgba(71,137,225,.18), inset 0 0 24px rgba(111,170,245,.09);
}

.extension-timer-panel.extension-base-lower .timer-face {
  background:linear-gradient(145deg,rgba(78,50,112,.96),rgba(18,10,30,.97));
  border-color:rgba(178,135,226,.55);
  box-shadow:0 0 24px rgba(129,79,184,.18), inset 0 0 24px rgba(171,119,224,.09);
}
.section-heading { margin-bottom:8px; }
.section-heading h2 { font-size:clamp(1.35rem,2vw,2.2rem); line-height:1.1; overflow-wrap:anywhere; }
.timer-grid { display:grid; grid-template-columns:repeat(var(--timer-count,4),minmax(0,1fr)); gap:clamp(8px,1vw,16px); }
.timer-panel { min-width:0; perspective:1200px; transition:opacity .35s ease; }
.timer-panel-inner { display:grid; width:100%; min-height:clamp(180px,18vw,300px); transform-style:preserve-3d; transition:transform .7s ease-in-out; }
.timer-panel.is-finished { opacity:.7; }
.timer-panel.is-finished .timer-panel-inner { transform:rotateY(180deg); }
.timer-face { grid-area:1/1; display:grid; width:100%; padding:clamp(10px,1vw,18px); border:1px solid rgba(182,211,255,.55); border-radius:clamp(16px,1.5vw,26px); background:linear-gradient(145deg,rgba(32,50,78,.92),rgba(6,12,23,.96)); box-shadow:0 0 24px rgba(116,168,255,.16), inset 0 0 24px rgba(158,200,255,.08); text-align:center; backface-visibility:hidden; -webkit-backface-visibility:hidden; }
.timer-face-front { align-content:center; gap:clamp(8px,1vw,16px); }
.timer-face-back { align-content:center; justify-items:center; gap:14px; transform:rotateY(180deg); }
.timer-title { max-width:100%; margin:0; color:#eef5ff; font-size:clamp(1.15rem,2vw,2.25rem); font-weight:800; line-height:1.05; text-shadow:0 0 16px rgba(159,198,255,.36); overflow-wrap:anywhere; }
.time-display { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; width:100%; }
.minutes-display, .seconds-display, .time-colon { color:#fff; font-family:Consolas,"Roboto Mono","Courier New",monospace; font-weight:700; line-height:.9; text-shadow:0 0 11px rgba(255,255,255,.52),0 0 26px rgba(153,194,255,.22); transition:color .2s ease,text-shadow .2s ease; }
.minutes-display, .seconds-display { min-width:0; font-size:clamp(3.2rem,6.7vw,7.3rem); letter-spacing:-.09em; }
.time-colon { padding:0 .02em .08em; font-size:clamp(2.4rem,4.3vw,4.8rem); }
.time-display.is-warning .minutes-display, .time-display.is-warning .seconds-display, .time-display.is-warning .time-colon { color:#ff4b4b; text-shadow:0 0 11px rgba(255,75,75,.68),0 0 28px rgba(255,0,0,.24); }
.finished-message { display:grid; justify-items:center; gap:8px; width:100%; }
.finished-title, .finished-text { max-width:100%; margin:0; color:#fff; font-weight:800; line-height:1.05; overflow-wrap:anywhere; text-shadow:0 0 14px rgba(255,255,255,.4),0 0 28px rgba(153,194,255,.24); }
.finished-title { font-size:clamp(1.5rem,2.4vw,2.8rem); }
.finished-text { font-size:clamp(2.8rem,4.7vw,5.2rem); letter-spacing:.08em; }
.extension-info { display:grid; gap:2px; }
.extension-history,
.extension-total { margin:0; color:#f6d88d; font-size:clamp(.68rem,.9vw,.88rem); font-weight:700; line-height:1.25; overflow-wrap:anywhere; }
.extension-total { color:#ffe6a8; }
.extension-panel-actions { display:flex; justify-content:center; gap:8px; }
.extension-panel-actions .secondary-button { min-height:36px; padding:6px 12px; font-size:.85rem; }
.interaction-controls { opacity:0; pointer-events:none; transition:opacity .18s ease; }
.show-interaction-controls .interaction-controls { opacity:1; pointer-events:auto; }

.floating-controls { position:fixed; left:50%; bottom:0; z-index:50; display:flex; gap:10px; width:min(1000px,calc(100% - 24px)); padding:10px 12px; border:1px solid rgba(154,191,245,.35); border-bottom:0; border-radius:16px 16px 0 0; background:rgba(5,11,22,.96); box-shadow:0 -8px 30px rgba(0,0,0,.34); transform:translate(-50%,calc(100% - 5px)); opacity:.05; transition:transform .28s ease,opacity .2s ease; }
.floating-controls.is-visible { transform:translate(-50%,0); opacity:1; }
.control-button { flex:1 1 0; min-width:0; min-height:48px; white-space:nowrap; }
.finish-button { flex:.65 1 0; }

.extension-dialog { width:min(560px,calc(100% - 28px)); border:1px solid rgba(148,187,245,.45); border-radius:20px; background:linear-gradient(145deg,#15233a,#070d18); color:#f7f9ff; box-shadow:0 20px 60px rgba(0,0,0,.58); }
.extension-dialog::backdrop { background:rgba(0,0,0,.68); backdrop-filter:blur(3px); }
.extension-form { display:grid; gap:16px; padding:4px; }
.extension-form h2 { margin:0 0 2px; font-size:1.45rem; }
.extension-current-total { padding:10px 12px; border:1px solid rgba(241,194,95,.3); border-radius:12px; background:rgba(142,91,12,.16); color:#f6d88d; font-weight:700; }
.dialog-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:4px; }

.visually-hidden { position:absolute!important; width:1px!important; height:1px!important; padding:0!important; margin:-1px!important; overflow:hidden!important; clip:rect(0,0,0,0)!important; white-space:nowrap!important; border:0!important; }

/* PCで3段をできるだけ1画面に収める */
@media (min-width:901px) {
  .board-screen { padding-bottom:10px; }
  .board-screen .page-header h1 { font-size:clamp(1.5rem,2vw,2.4rem); }
  .board-screen .timer-panel-inner { min-height:clamp(165px,17vh,255px); }
  .board-screen .minutes-display, .board-screen .seconds-display { font-size:clamp(3rem,5.4vw,6.2rem); }
  .board-screen .time-colon { font-size:clamp(2.1rem,3.4vw,4rem); }
  body.board-compact .app-shell { padding-top:10px; padding-bottom:8px; }
  body.board-compact .board-header { margin-bottom:8px; }
  body.board-compact .timer-section { margin-bottom:8px; padding:8px 10px; }
  body.board-compact .section-heading { margin-bottom:5px; }
  body.board-compact .section-heading h2 { font-size:clamp(1.15rem,1.55vw,1.7rem); }
  body.board-compact .timer-panel-inner { min-height:clamp(135px,15.5vh,210px); }
  body.board-compact .timer-title { font-size:clamp(1rem,1.45vw,1.55rem); }
  body.board-compact .minutes-display, body.board-compact .seconds-display { font-size:clamp(2.6rem,4.7vw,5rem); }
  body.board-compact .time-colon { font-size:clamp(1.9rem,3vw,3.4rem); }
  body.board-extra-compact .board-header { margin-bottom:4px; }
  body.board-extra-compact .board-header h1 { font-size:1.35rem; }
  body.board-extra-compact .timer-section { margin-bottom:5px; padding:6px 8px; }
  body.board-extra-compact .timer-panel-inner { min-height:120px; }
  body.board-extra-compact .timer-title { font-size:.95rem; }
  body.board-extra-compact .minutes-display, body.board-extra-compact .seconds-display { font-size:clamp(2.2rem,4.2vw,4.3rem); }
  body.board-extra-compact .time-colon { font-size:2.4rem; }
  body.board-extra-compact .extension-history, body.board-extra-compact .extension-total { font-size:.64rem; }
}

@media (max-width:1100px) and (min-width:701px) {
  .timer-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
}

@media (max-width:900px) {
  .board-screen { padding-bottom:74px; }
  .timer-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
  .timer-panel-inner { min-height:250px; }
  .minutes-display, .seconds-display { font-size:clamp(4rem,12vw,7rem); }
  .floating-controls { width:calc(100% - 12px); gap:6px; padding:8px; }
  .control-button { font-size:.82rem; padding:8px 6px; }
}

@media (max-width:700px) {
  .setup-group-header, .timer-setting-row { grid-template-columns:1fr; }
  .duration-fields, .extension-duration-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
}

@media (max-width:560px) {
  .app-shell { padding:10px; }
  .timer-grid { grid-template-columns:1fr; }
  .timer-panel-inner { min-height:300px; }
  .minutes-display, .seconds-display { font-size:clamp(5.2rem,29vw,9rem); }
  .time-colon { font-size:clamp(3.6rem,15vw,5.4rem); }
  .floating-controls { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
  .finish-button { grid-column:span 2; }
  .dialog-actions { display:grid; grid-template-columns:1fr 1fr; }
}

@media (prefers-reduced-motion:reduce) {
  * { scroll-behavior:auto!important; transition:none!important; }
}

/* 第7回:累積時間/個別時間の選択 */
.time-mode-fieldset {
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:10px 18px;
  margin:0 0 18px;
  padding:12px 14px;
  border:1px solid rgba(131,169,224,.22);
  border-radius:14px;
  background:rgba(5,12,23,.36);
}
.time-mode-fieldset legend {
  padding:0 6px;
  color:#dbe8fb;
  font-weight:700;
}
.radio-option {
  display:inline-flex;
  align-items:center;
  gap:7px;
  color:#eef5ff;
  font-weight:700;
  cursor:pointer;
}
.radio-option input[type="radio"] {
  width:18px;
  min-height:auto;
  height:18px;
  margin:0;
  accent-color:#5d93ff;
}

/* 第7回:段全体の終了回転 */
.timer-section-upper,
.timer-section-lower {
  perspective:1600px;
}
.group-flip-card {
  display:grid;
  width:100%;
  transform-style:preserve-3d;
  transition:transform .7s ease-in-out;
}
.group-flip-card.is-group-finished {
  transform:rotateY(180deg);
}
.group-face {
  grid-area:1 / 1;
  width:100%;
  backface-visibility:hidden;
  -webkit-backface-visibility:hidden;
}
.group-face-back {
  display:grid;
  place-content:center;
  justify-items:center;
  min-height:100%;
  padding:clamp(18px,2vw,32px);
  transform:rotateY(180deg);
  text-align:center;
}
.group-finished-title,
.group-finished-text {
  max-width:100%;
  margin:0;
  color:#fff;
  font-weight:800;
  overflow-wrap:anywhere;
  text-shadow:0 0 14px rgba(255,255,255,.42),0 0 30px rgba(153,194,255,.24);
}
.group-finished-title {
  font-size:clamp(1.8rem,3vw,3.6rem);
}
.group-finished-text {
  margin-top:12px;
  font-size:clamp(3.6rem,6vw,7rem);
  letter-spacing:.1em;
}

/* 第7回:通常タイマー全終了時の1.5秒フェード */
.board-finish-overlay {
  position:fixed;
  inset:0;
  z-index:40;
  display:grid;
  place-items:center;
  padding:24px;
  pointer-events:none;
  opacity:0;
  visibility:hidden;
  transition:opacity var(--board-finish-fade-ms,1500ms) ease, visibility 0s linear var(--board-finish-fade-ms,1500ms);
}
.board-finish-overlay.is-visible {
  opacity:1;
  visibility:visible;
  transition:opacity var(--board-finish-fade-ms,1500ms) ease;
}
.board-finish-message {
  width:min(760px,90vw);
  padding:clamp(24px,4vw,50px);
  border:1px solid rgba(205,224,255,.34);
  border-radius:28px;
  background:rgba(2,7,15,.78);
  box-shadow:0 20px 70px rgba(0,0,0,.45), inset 0 0 34px rgba(120,170,255,.08);
  backdrop-filter:blur(5px);
  text-align:center;
}
.board-finished-title,
.board-finished-text {
  margin:0;
  color:#fff;
  font-weight:800;
  line-height:1.05;
  overflow-wrap:anywhere;
  text-shadow:0 0 16px rgba(255,255,255,.48),0 0 34px rgba(141,189,255,.28);
}
.board-finished-title { font-size:clamp(2rem,4vw,4.4rem); }
.board-finished-text { margin-top:18px; font-size:clamp(4rem,8vw,8rem); letter-spacing:.12em; }

@media (max-width:560px) {
  .time-mode-fieldset { align-items:flex-start; flex-direction:column; }
  .board-finish-message { padding:24px 16px; }
}

script.js

script.js のソースコードを表示
const DEFAULT_MINUTES = 5;
const DEFAULT_SECONDS = 0;
const UPDATE_INTERVAL = 200;
const WARNING_THRESHOLD_MS = 60 * 1000;
const CONTROL_HIDE_DELAY = 3000;
const MAX_EXTENSION_TIMERS = 4;
const BOARD_FINISH_FADE_MS = 1500;

const setupScreen = document.getElementById("setup-screen");
const boardScreen = document.getElementById("board-screen");
const setupForm = document.getElementById("setup-form");
const setupError = document.getElementById("setup-error");
const boardTitleInput = document.getElementById("board-title-input");
const upperTitleInput = document.getElementById("upper-title");
const lowerTitleInput = document.getElementById("lower-title");
const upperCountSelect = document.getElementById("upper-count");
const lowerCountSelect = document.getElementById("lower-count");
const upperSettingsContainer = document.getElementById("upper-timer-settings");
const lowerSettingsContainer = document.getElementById("lower-timer-settings");
const settingRowTemplate = document.getElementById("setting-row-template");
const upperHeading = document.getElementById("upper-heading");
const upperGroupFlip = document.getElementById("upper-group-flip");
const lowerGroupFlip = document.getElementById("lower-group-flip");
const upperFinishedTitle = document.getElementById("upper-finished-title");
const lowerFinishedTitle = document.getElementById("lower-finished-title");
const lowerHeading = document.getElementById("lower-heading");
const boardTitle = document.getElementById("board-title");
const lowerTimerSection = document.getElementById("lower-timer-section");
const extensionTimerSection = document.getElementById("extension-timer-section");
const upperTimerGrid = document.getElementById("upper-timer-grid");
const lowerTimerGrid = document.getElementById("lower-timer-grid");
const extensionTimerGrid = document.getElementById("extension-timer-grid");
const timerPanelTemplate = document.getElementById("timer-panel-template");
const startAllButton = document.getElementById("start-all-button");
const resetAllButton = document.getElementById("reset-all-button");
const addExtensionButton = document.getElementById("add-extension-button");
const finishButton = document.getElementById("finish-button");
const floatingControls = document.getElementById("floating-controls");
const extensionDialog = document.getElementById("extension-dialog");
const extensionForm = document.getElementById("extension-form");
const extensionDialogTitle = document.getElementById("extension-dialog-title");
const extensionNameField = document.getElementById("extension-name-field");
const extensionBaseField = document.getElementById("extension-base-field");
const extensionNameInput = document.getElementById("extension-name");
const extensionBaseGroup = document.getElementById("extension-base-group");
const extensionMinutesInput = document.getElementById("extension-minutes");
const extensionSecondsInput = document.getElementById("extension-seconds");
const extensionError = document.getElementById("extension-error");
const extensionCurrentTotal = document.getElementById("extension-current-total");
const extensionSubmitButton = document.getElementById("extension-submit-button");
const extensionCancelButton = document.getElementById("extension-cancel-button");
const boardFinishOverlay = document.getElementById("board-finish-overlay");
const boardFinishedTitle = document.getElementById("board-finished-title");

const settingsState = {
  boardTitle: "試験用キッチンタイマー",
  upper: { title: "上段タイトル", count: 4, timeMode: "cumulative", timers: createDefaultTimerSettings(4, 1) },
  lower: { title: "下段タイトル", count: 3, timeMode: "cumulative", timers: createDefaultTimerSettings(3, 5) }
};

let timers = [];
let extensionTimers = [];
let boardStarted = false;
let controlHideTimerId = null;
let extensionDialogMode = "create";
let editingExtensionTimer = null;
let groupFinishedState = { upper: false, lower: false };
let boardFinishShown = false;

function createDefaultTimerSettings(count, startNumber) {
  return Array.from({ length: count }, (_, index) => ({
    name: `タイマー${startNumber + index}`,
    minutes: DEFAULT_MINUTES,
    seconds: DEFAULT_SECONDS
  }));
}

function padNumber(value) { return String(value).padStart(2, "0"); }

function formatTime(milliseconds) {
  const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
  return { minutes: padNumber(Math.floor(totalSeconds / 60)), seconds: padNumber(totalSeconds % 60) };
}

function formatDuration(milliseconds) {
  const totalSeconds = Math.max(0, Math.round(milliseconds / 1000));
  return `${Math.floor(totalSeconds / 60)}分${padNumber(totalSeconds % 60)}秒`;
}

function getGroupDisplayTitle(groupKey) {
  if (groupKey === "upper") {
    return settingsState.upper.title.trim() || "上段";
  }

  return settingsState.lower.title.trim() || "下段";
}

function buildExtensionDisplayName(baseGroup, enteredName) {
  return `${getGroupDisplayTitle(baseGroup)}|${enteredName}`;
}

function updateLowerCountOptions() {
  const upperCount = Number(upperCountSelect.value);

  [...lowerCountSelect.options].forEach((option) => {
    const optionCount = Number(option.value);
    option.disabled = optionCount > upperCount;
  });

  if (Number(lowerCountSelect.value) > upperCount) {
    saveCurrentSetupInputs();
    lowerCountSelect.value = String(upperCount);
    normalizeTimerSettings("lower", upperCount);
    renderSettingRows("lower", lowerSettingsContainer);
    lowerSettingsContainer.hidden = upperCount === 0;
  }
}

function getInputInteger(input, minimum, maximum = null) {
  const value = Number(input.value);
  if (!Number.isInteger(value) || value < minimum) return null;
  if (maximum !== null && value > maximum) return null;
  return value;
}

function normalizeTimerSettings(groupKey, count) {
  const group = settingsState[groupKey];
  const startNumber = groupKey === "upper" ? 1 : settingsState.upper.count + 1;
  while (group.timers.length < count) {
    const index = group.timers.length;
    group.timers.push({ name: `タイマー${startNumber + index}`, minutes: DEFAULT_MINUTES, seconds: DEFAULT_SECONDS });
  }
  group.count = count;
}

function readVisibleSettingRows(groupKey, container) {
  [...container.querySelectorAll(".timer-setting-row")].forEach((row, index) => {
    const timerSetting = settingsState[groupKey].timers[index];
    timerSetting.name = row.querySelector(".timer-name-input").value;
    timerSetting.minutes = row.querySelector(".minutes-input").value;
    timerSetting.seconds = row.querySelector(".seconds-input").value;
  });
}

function saveCurrentSetupInputs() {
  settingsState.boardTitle = boardTitleInput.value;
  settingsState.upper.title = upperTitleInput.value;
  settingsState.lower.title = lowerTitleInput.value;
  settingsState.upper.timeMode = setupForm.querySelector('input[name="upper-time-mode"]:checked')?.value || "cumulative";
  settingsState.lower.timeMode = setupForm.querySelector('input[name="lower-time-mode"]:checked')?.value || "cumulative";
  readVisibleSettingRows("upper", upperSettingsContainer);
  readVisibleSettingRows("lower", lowerSettingsContainer);
}

function renderSettingRows(groupKey, container) {
  const group = settingsState[groupKey];
  container.replaceChildren();
  group.timers.slice(0, group.count).forEach((timerSetting, index) => {
    const row = settingRowTemplate.content.firstElementChild.cloneNode(true);
    row.querySelector(".timer-name-label").textContent = `タイマー${index + 1}の名前`;
    row.querySelector(".timer-name-input").value = timerSetting.name;
    row.querySelector(".minutes-input").value = timerSetting.minutes;
    row.querySelector(".seconds-input").value = timerSetting.seconds;
    container.appendChild(row);
  });
}

function handleCountChange(groupKey, selectElement, container) {
  saveCurrentSetupInputs();
  const count = Number(selectElement.value);
  normalizeTimerSettings(groupKey, count);
  renderSettingRows(groupKey, container);
  if (groupKey === "lower") container.hidden = count === 0;
}

class TimerPanel {
  constructor({
    name,
    totalMilliseconds,
    group,
    isExtension = false,
    extensionTotalMilliseconds = 0,
    extensionHistoryMilliseconds = [],
    baseGroup = null
  }, panelElement) {
    this.name = name;
    this.totalMilliseconds = totalMilliseconds;
    this.group = group;
    this.isExtension = isExtension;
    this.extensionTotalMilliseconds = extensionTotalMilliseconds;
    this.extensionHistoryMilliseconds = [...extensionHistoryMilliseconds];
    this.baseGroup = baseGroup;
    this.panel = panelElement;
    this.remainingMilliseconds = totalMilliseconds;
    this.endTime = 0;
    this.intervalId = null;
    this.isRunning = false;
    this.isFinished = false;
    this.titleElement = panelElement.querySelector(".timer-title");
    this.minutesDisplay = panelElement.querySelector(".minutes-display");
    this.secondsDisplay = panelElement.querySelector(".seconds-display");
    this.timeDisplay = panelElement.querySelector(".time-display");
    this.finishedTitle = panelElement.querySelector(".finished-title");
    this.extensionInfoElement = panelElement.querySelector(".extension-info");
    this.extensionHistoryElement = panelElement.querySelector(".extension-history");
    this.extensionTotalElement = panelElement.querySelector(".extension-total");
    this.titleElement.textContent = this.name;
    this.finishedTitle.textContent = this.name;
    this.setupExtensionUi();
    this.updateDisplay(this.remainingMilliseconds);
  }

  setupExtensionUi() {
    if (!this.isExtension) return;

    this.panel.classList.add(
      "extension-timer-panel",
      this.baseGroup === "upper" ? "extension-base-upper" : "extension-base-lower"
    );

    this.extensionInfoElement.hidden = false;
    this.updateExtensionInfoText();

    this.panel.querySelectorAll(".extension-panel-actions").forEach((actions) => actions.hidden = false);
    this.panel.querySelectorAll(".extension-add-button").forEach((button) => button.addEventListener("click", () => openExtensionDialogForUpdate(this)));
    this.panel.querySelectorAll(".extension-delete-button").forEach((button) => button.addEventListener("click", () => deleteExtensionTimer(this)));
  }

  updateExtensionInfoText() {
    if (this.extensionHistoryElement) {
      const historyText = this.extensionHistoryMilliseconds
        .map((milliseconds) => formatDuration(milliseconds))
        .join(" + ");

      this.extensionHistoryElement.textContent = `延長時間:${historyText}`;
    }

    if (this.extensionTotalElement) {
      this.extensionTotalElement.textContent = `延長時間合計:${formatDuration(this.extensionTotalMilliseconds)}`;
    }
  }

  canStart() { return !this.isRunning && !this.isFinished; }

  start() {
    if (!this.canStart()) return;
    this.endTime = Date.now() + this.remainingMilliseconds;
    this.isRunning = true;
    this.panel.classList.add("is-running");
    this.updateFromClock();
    this.intervalId = window.setInterval(() => this.updateFromClock(), UPDATE_INTERVAL);
  }

  startFromEndTime(endTime) {
    this.endTime = endTime;
    this.remainingMilliseconds = Math.max(0, endTime - Date.now());
    this.isFinished = false;
    this.isRunning = this.remainingMilliseconds > 0;
    this.panel.classList.remove("is-finished");
    this.panel.classList.toggle("is-running", this.isRunning);
    this.updateDisplay(this.remainingMilliseconds);
    window.clearInterval(this.intervalId);
    if (this.isRunning) this.intervalId = window.setInterval(() => this.updateFromClock(), UPDATE_INTERVAL);
    else this.finish();
  }

  addExtension(milliseconds) {
    this.extensionHistoryMilliseconds.push(milliseconds);
    this.extensionTotalMilliseconds += milliseconds;
    this.updateExtensionInfoText();
    const baseEndTime = getBaseGroupEndTime(this.baseGroup);
    const newEndTime = baseEndTime + this.extensionTotalMilliseconds;
    this.startFromEndTime(newEndTime);
  }

  updateFromClock() {
    if (!this.isRunning) return;
    this.remainingMilliseconds = Math.max(0, this.endTime - Date.now());
    this.updateDisplay(this.remainingMilliseconds);
    if (this.remainingMilliseconds <= 0) this.finish();
  }

  updateDisplay(milliseconds) {
    const formatted = formatTime(milliseconds);
    this.minutesDisplay.textContent = formatted.minutes;
    this.secondsDisplay.textContent = formatted.seconds;
    const warning = milliseconds > 0 && milliseconds < WARNING_THRESHOLD_MS;
    this.timeDisplay.classList.toggle("is-warning", warning);
  }

  finish() {
    window.clearInterval(this.intervalId);
    this.intervalId = null;
    this.isRunning = false;
    this.isFinished = true;
    this.remainingMilliseconds = 0;
    this.updateDisplay(0);
    this.panel.classList.remove("is-running");
    this.panel.classList.add("is-finished");
    updateControlStates();
    if (!this.isExtension) handleNormalTimerFinished(this);
  }

  reset() {
    window.clearInterval(this.intervalId);
    this.intervalId = null;
    this.remainingMilliseconds = this.totalMilliseconds;
    this.endTime = 0;
    this.isRunning = false;
    this.isFinished = false;
    this.panel.classList.remove("is-running", "is-finished");
    this.updateDisplay(this.remainingMilliseconds);
  }

  stopAndDispose() {
    window.clearInterval(this.intervalId);
    this.intervalId = null;
    this.isRunning = false;
  }
}

function validateGroup(groupKey, container) {
  const group = settingsState[groupKey];
  const rows = [...container.querySelectorAll(".timer-setting-row")];
  const parsedTimers = [];
  for (let index = 0; index < rows.length; index += 1) {
    const row = rows[index];
    const minutes = getInputInteger(row.querySelector(".minutes-input"), 0);
    const seconds = getInputInteger(row.querySelector(".seconds-input"), 0, 59);
    const label = groupKey === "upper" ? "上段" : "下段";
    if (minutes === null) return { error: `${label}タイマー${index + 1}の分は、0以上の整数で入力してください。` };
    if (seconds === null) return { error: `${label}タイマー${index + 1}の秒は、0~59の整数で入力してください。` };
    if (minutes === 0 && seconds === 0) return { error: `${label}タイマー${index + 1}は、0分0秒では作成できません。` };
    const fallbackNumber = groupKey === "upper" ? index + 1 : settingsState.upper.count + index + 1;
    const name = row.querySelector(".timer-name-input").value.trim() || `タイマー${fallbackNumber}`;
    parsedTimers.push({ name, minutes, seconds, durationMilliseconds: (minutes * 60 + seconds) * 1000 });
  }
  return { timers: parsedTimers };
}

function makeDisplayTimers(groupKey, parsedTimers) {
  if (settingsState[groupKey].timeMode === "individual") {
    return parsedTimers.map((timer) => ({
      name: timer.name,
      totalMilliseconds: timer.durationMilliseconds,
      group: groupKey
    }));
  }

  let cumulativeMilliseconds = 0;
  return parsedTimers.map((timer) => {
    cumulativeMilliseconds += timer.durationMilliseconds;
    return { name: timer.name, totalMilliseconds: cumulativeMilliseconds, group: groupKey };
  });
}

function createTimerPanel(timerData) {
  const panel = timerPanelTemplate.content.firstElementChild.cloneNode(true);
  const timer = new TimerPanel(timerData, panel);
  return { panel, timer };
}

function resetFinishPresentation() {
  groupFinishedState = { upper: false, lower: false };
  boardFinishShown = false;
  upperGroupFlip.classList.remove("is-group-finished");
  lowerGroupFlip.classList.remove("is-group-finished");
  boardFinishOverlay.classList.remove("is-visible");
  boardFinishOverlay.setAttribute("aria-hidden", "true");
}

function finishGroup(groupKey) {
  if (groupFinishedState[groupKey]) return;
  const groupTimers = getGroupTimers(groupKey);
  if (!groupTimers.length || !groupTimers.every((timer) => timer.isFinished)) return;

  groupFinishedState[groupKey] = true;
  if (groupKey === "upper") upperGroupFlip.classList.add("is-group-finished");
  if (groupKey === "lower") lowerGroupFlip.classList.add("is-group-finished");
  checkBoardFinished();
}

function checkBoardFinished() {
  const upperDone = groupFinishedState.upper;
  const lowerRequired = settingsState.lower.count > 0;
  const lowerDone = !lowerRequired || groupFinishedState.lower;
  if (!upperDone || !lowerDone || boardFinishShown) return;

  boardFinishShown = true;
  boardFinishedTitle.textContent = settingsState.boardTitle.trim() || "試験用キッチンタイマー";
  boardFinishOverlay.style.setProperty("--board-finish-fade-ms", `${BOARD_FINISH_FADE_MS}ms`);
  boardFinishOverlay.setAttribute("aria-hidden", "false");
  requestAnimationFrame(() => boardFinishOverlay.classList.add("is-visible"));
}

function handleNormalTimerFinished(timer) {
  finishGroup(timer.group);
}

function renderBoard(upperTimers, lowerTimers) {
  [...timers, ...extensionTimers].forEach((timer) => timer.stopAndDispose());
  timers = [];
  extensionTimers = [];
  boardStarted = false;
  upperTimerGrid.replaceChildren();
  lowerTimerGrid.replaceChildren();
  extensionTimerGrid.replaceChildren();
  extensionTimerSection.hidden = true;
  resetFinishPresentation();
  const referenceColumnCount = upperTimers.length;
  upperTimerGrid.style.setProperty("--timer-count", String(referenceColumnCount));
  lowerTimerGrid.style.setProperty("--timer-count", String(referenceColumnCount));
  extensionTimerGrid.style.setProperty("--timer-count", String(referenceColumnCount));

  upperTimers.forEach((timerData) => {
    const { panel, timer } = createTimerPanel(timerData);
    upperTimerGrid.appendChild(panel); timers.push(timer);
  });
  lowerTimers.forEach((timerData) => {
    const { panel, timer } = createTimerPanel(timerData);
    lowerTimerGrid.appendChild(panel); timers.push(timer);
  });

  boardTitle.textContent = settingsState.boardTitle.trim() || "試験用キッチンタイマー";
  upperHeading.textContent = settingsState.upper.title.trim() || "上段タイトル";
  lowerHeading.textContent = settingsState.lower.title.trim() || "下段タイトル";
  upperFinishedTitle.textContent = settingsState.upper.title.trim() || "上段タイトル";
  lowerFinishedTitle.textContent = settingsState.lower.title.trim() || "下段タイトル";
  lowerTimerSection.hidden = lowerTimers.length === 0;
  updateControlStates();
  requestAnimationFrame(updateBoardDensity);
}

function getGroupTimers(groupKey) { return timers.filter((timer) => timer.group === groupKey); }

function getBaseGroupEndTime(groupKey) {
  const groupTimers = getGroupTimers(groupKey);
  if (!groupTimers.length) return Date.now();
  return Math.max(...groupTimers.map((timer) => timer.endTime || (Date.now() + timer.remainingMilliseconds)));
}

function updateControlStates() {
  startAllButton.disabled = boardStarted || !timers.some((timer) => timer.canStart());
  addExtensionButton.disabled = !boardStarted || extensionTimers.length >= MAX_EXTENSION_TIMERS;
}

function startAllTimers() {
  if (boardStarted) return;
  const startTime = Date.now();
  timers.forEach((timer) => {
    timer.endTime = startTime + timer.remainingMilliseconds;
    timer.isRunning = true;
    timer.panel.classList.add("is-running");
    timer.updateFromClock();
    timer.intervalId = window.setInterval(() => timer.updateFromClock(), UPDATE_INTERVAL);
  });
  boardStarted = true;
  updateControlStates();
}

function resetAllTimers() {
  const confirmed = window.confirm("すべてのタイマーをリセットします。個別延長タイマーも削除されます。よろしいですか?");
  if (!confirmed) return;
  timers.forEach((timer) => timer.reset());
  extensionTimers.forEach((timer) => timer.stopAndDispose());
  extensionTimers = [];
  extensionTimerGrid.replaceChildren();
  extensionTimerSection.hidden = true;
  boardStarted = false;
  resetFinishPresentation();
  updateControlStates();
  requestAnimationFrame(updateBoardDensity);
}

function populateBaseGroupOptions() {
  extensionBaseGroup.replaceChildren();
  const upperOption = new Option(settingsState.upper.title.trim() || "上段", "upper");
  extensionBaseGroup.add(upperOption);
  if (settingsState.lower.count > 0) {
    const lowerOption = new Option(settingsState.lower.title.trim() || "下段", "lower");
    extensionBaseGroup.add(lowerOption);
  }
}

function resetExtensionDialogInputs() {
  extensionMinutesInput.value = "0";
  extensionSecondsInput.value = "0";
  extensionError.textContent = "";
}

function openExtensionDialogForCreate() {
  if (!boardStarted || extensionTimers.length >= MAX_EXTENSION_TIMERS) return;
  extensionDialogMode = "create";
  editingExtensionTimer = null;
  extensionDialogTitle.textContent = "個別延長タイマーを作成";
  extensionSubmitButton.textContent = "作成";
  extensionNameField.hidden = false;
  extensionBaseField.hidden = false;
  extensionCurrentTotal.hidden = true;
  extensionNameInput.value = "";
  populateBaseGroupOptions();
  resetExtensionDialogInputs();
  extensionDialog.showModal();
  extensionNameInput.focus();
}

function openExtensionDialogForUpdate(timer) {
  extensionDialogMode = "update";
  editingExtensionTimer = timer;
  extensionDialogTitle.textContent = `${timer.name} に延長時間を追加`;
  extensionSubmitButton.textContent = "更新";
  extensionNameField.hidden = true;
  extensionBaseField.hidden = true;
  extensionCurrentTotal.hidden = false;
  extensionCurrentTotal.textContent = `現在の延長合計:${formatDuration(timer.extensionTotalMilliseconds)}`;
  resetExtensionDialogInputs();
  extensionDialog.showModal();
  extensionMinutesInput.focus();
}

function closeExtensionDialog() {
  if (extensionDialog.open) extensionDialog.close();
  editingExtensionTimer = null;
}

function createExtensionTimer(enteredName, baseGroup, extensionMilliseconds) {
  const baseEndTime = getBaseGroupEndTime(baseGroup);
  const endTime = baseEndTime + extensionMilliseconds;
  const remainingMilliseconds = Math.max(0, endTime - Date.now());
  const displayName = buildExtensionDisplayName(baseGroup, enteredName);

  const { panel, timer } = createTimerPanel({
    name: displayName,
    totalMilliseconds: remainingMilliseconds,
    group: "extension",
    isExtension: true,
    extensionTotalMilliseconds: extensionMilliseconds,
    extensionHistoryMilliseconds: [extensionMilliseconds],
    baseGroup
  });
  timer.startFromEndTime(endTime);
  extensionTimerGrid.appendChild(panel);
  extensionTimers.push(timer);
  extensionTimerGrid.style.setProperty("--timer-count", String(settingsState.upper.count));
  extensionTimerSection.hidden = false;
  updateControlStates();
  showInteractionControls();
  requestAnimationFrame(updateBoardDensity);
}

function deleteExtensionTimer(timer) {
  const confirmed = window.confirm(`${timer.name} の延長タイマーを削除しますか?`);
  if (!confirmed) return;
  timer.stopAndDispose();
  timer.panel.remove();
  extensionTimers = extensionTimers.filter((item) => item !== timer);
  extensionTimerGrid.style.setProperty("--timer-count", String(settingsState.upper.count));
  extensionTimerSection.hidden = extensionTimers.length === 0;
  updateControlStates();
  requestAnimationFrame(updateBoardDensity);
}

function handleExtensionSubmit(event) {
  event.preventDefault();
  const minutes = getInputInteger(extensionMinutesInput, 0);
  const seconds = getInputInteger(extensionSecondsInput, 0, 59);
  if (minutes === null) { extensionError.textContent = "分は0以上の整数で入力してください。"; return; }
  if (seconds === null) { extensionError.textContent = "秒は0~59の整数で入力してください。"; return; }
  if (minutes === 0 && seconds === 0) { extensionError.textContent = "延長時間を入力してください。"; return; }
  const extensionMilliseconds = (minutes * 60 + seconds) * 1000;

  if (extensionDialogMode === "create") {
    if (extensionTimers.length >= MAX_EXTENSION_TIMERS) { extensionError.textContent = "個別延長タイマーは最大4個までです。"; return; }
    const enteredName = extensionNameInput.value.trim() || `個別延長${extensionTimers.length + 1}`;
    createExtensionTimer(enteredName, extensionBaseGroup.value, extensionMilliseconds);
  } else if (editingExtensionTimer) {
    editingExtensionTimer.addExtension(extensionMilliseconds);
  }
  closeExtensionDialog();
}

function showInteractionControls() {
  if (boardScreen.hidden) return;
  floatingControls.classList.add("is-visible");
  document.body.classList.add("show-interaction-controls");
  window.clearTimeout(controlHideTimerId);
  controlHideTimerId = window.setTimeout(hideInteractionControls, CONTROL_HIDE_DELAY);
}

function hideInteractionControls() {
  floatingControls.classList.remove("is-visible");
  document.body.classList.remove("show-interaction-controls");
}

function updateBoardDensity() {
  if (boardScreen.hidden) return;
  const isDesktop = window.matchMedia("(min-width: 901px)").matches;
  document.body.classList.remove("board-compact", "board-extra-compact");
  if (!isDesktop) return;
  const viewportHeight = window.innerHeight;
  if (viewportHeight < 950 || !extensionTimerSection.hidden) document.body.classList.add("board-compact");
  if (viewportHeight < 760) document.body.classList.add("board-extra-compact");
}

function showBoardScreen() {
  setupScreen.hidden = true;
  boardScreen.hidden = false;
  window.scrollTo({ top: 0, behavior: "auto" });
  showInteractionControls();
  requestAnimationFrame(updateBoardDensity);
}

function showSetupScreen() {
  boardScreen.hidden = true;
  setupScreen.hidden = false;
  window.clearTimeout(controlHideTimerId);
  document.body.classList.remove("show-interaction-controls", "board-compact", "board-extra-compact");
  window.scrollTo({ top: 0, behavior: "auto" });
}

setupForm.addEventListener("submit", (event) => {
  event.preventDefault();
  saveCurrentSetupInputs();
  const upperResult = validateGroup("upper", upperSettingsContainer);
  if (upperResult.error) { setupError.textContent = upperResult.error; return; }
  const lowerResult = validateGroup("lower", lowerSettingsContainer);
  if (lowerResult.error) { setupError.textContent = lowerResult.error; return; }

  settingsState.upper.timers = upperResult.timers.map(({ name, minutes, seconds }) => ({ name, minutes, seconds }));
  settingsState.lower.timers = lowerResult.timers.map(({ name, minutes, seconds }) => ({ name, minutes, seconds }));
  setupError.textContent = "";
  renderBoard(makeDisplayTimers("upper", upperResult.timers), makeDisplayTimers("lower", lowerResult.timers));
  showBoardScreen();
});

upperCountSelect.addEventListener("change", () => {
  handleCountChange("upper", upperCountSelect, upperSettingsContainer);
  updateLowerCountOptions();
});

lowerCountSelect.addEventListener("change", () => handleCountChange("lower", lowerCountSelect, lowerSettingsContainer));
startAllButton.addEventListener("click", startAllTimers);
resetAllButton.addEventListener("click", resetAllTimers);
addExtensionButton.addEventListener("click", openExtensionDialogForCreate);
extensionForm.addEventListener("submit", handleExtensionSubmit);
extensionCancelButton.addEventListener("click", closeExtensionDialog);
finishButton.addEventListener("click", () => {
  const confirmed = window.confirm("設定画面へ戻ります。タイマーは終了します。よろしいですか?");
  if (!confirmed) return;
  [...timers, ...extensionTimers].forEach((timer) => timer.stopAndDispose());
  showSetupScreen();
});

["mousemove", "mousedown", "keydown", "touchstart"].forEach((eventName) => {
  window.addEventListener(eventName, showInteractionControls, { passive: true });
});
window.addEventListener("resize", () => { showInteractionControls(); updateBoardDensity(); });

renderSettingRows("upper", upperSettingsContainer);
renderSettingRows("lower", lowerSettingsContainer);
updateLowerCountOptions();
lowerSettingsContainer.hidden = settingsState.lower.count === 0;

累積時間と個別時間を切り替えられるようにする

第5回では、各課題へ入力した時間を段ごとに累積して表示する仕組みを作りました。今回は、この動作を固定せず、上段・下段それぞれで「累積時間」と「個別時間」を選べるようにしました。

累積時間

累積時間では、左から順に入力時間を足していきます。たとえば10分、20分、15分と設定した場合、表示される時間は10分、30分、45分になります。

個別時間

個別時間では、入力した時間をそのまま各タイマーへ設定します。同じ10分、20分、15分なら、それぞれ10分、20分、15分で同時にスタートします。

個別時間では、必ず左から順番に終了するとは限りません。通常運用では累積時間を使う予定ですが、別の試験や時間管理で使える可能性があるため、選択できるようにしておきました。

ラジオボタンを使う

今回は、同時にどちらか一方だけを選びたいので、チェックボックスではなくラジオボタンを使っています。上段と下段は別々の name を指定しているため、それぞれ独立して選択できます。

初期状態では「累積時間」を選択済みにしてあります。

上段・下段が終わったら、段全体を終了表示へ切り替える

これまでは、各タイマーが0になるたびに、そのタイマーパネルだけがクルッと回転して「終了」を表示していました。第7回では、通常タイマーがすべて終わった段について、段全体を終了表示へ切り替えます。

上段なら「上段タイトル+終了」、下段なら「下段タイトル+終了」を表示します。回転時間は、これまでのタイマーパネルと同じ約0.7秒です。

段全体の終了判定に使うのは通常タイマーだけです。個別延長タイマーは、この判定には含めません。

これは実際の試験運用に合わせたものです。通常時間内に終わった人は退出できますが、トラブル対応中の受験者だけは個別延長で試験を続ける場合があります。

上下段が終わったら、全体終了を1秒遅らせて表示する

上段と下段の通常タイマーがすべて終了したら、試験全体としても通常時間は終了です。そこで、画面タイトルと「終了」を中央へ大きく表示するようにしました。

最初の試作では、最後の段が終わるのとほぼ同時に全体終了も表示されてしまい、段の回転演出と全体終了のフェードが重なって見えました。

そこで、最後の段が終了してから1秒待ち、その後に全体終了を表示するように変更しました。

setTimeout()で1秒待つ

setTimeout()は、指定した時間が経過した後に処理を実行するJavaScriptの機能です。今回の場合は、上下段の終了を確認した後、1000ミリ秒、つまり1秒待ってから全体終了表示を呼び出しています。

1秒遅らせることで、「最後の段が回転して終了」→「全体終了がフェードイン」という順番になり、二つの演出が重なりにくくなりました。

全体終了は1.5秒でフェードイン

個別タイマーと段全体は回転する演出ですが、一番最後の全体終了だけは違う動きにしました。約1.5秒かけて静かにフェードインします。

試験中なので音は鳴らしません。終了したことを大きく表示しつつ、教室内では静かに試験を終えられるようにしています。

個別延長は、通常試験が終わってもそのまま残す

全体終了が表示されても、個別延長タイマーは消しません。通常時間内に終了した人は退出し、室内に残っている人は個別延長だけを見る流れを想定しています。

また、いったん個別延長タイマーが終了していても、「実はトラブル対応時間がもう少しあった」という場合があります。そのため、第6回で作った「+追加」もそのまま使用できます。

通常試験の終了表示と個別延長を別管理にしたことで、「通常試験は終了しているが、一部の受験者だけ延長中」という状態を画面上で表現できるようになりました。

個別延長の時間を減らせるようにする

個別延長では、基本的には時間を追加します。ただし、入力を間違えて予定より多く延長してしまう可能性もゼロではありません。

そこで、作成済みの個別延長タイマーへ時間を追加するときだけ、「増やす」「減らす」のラジオボタンを表示するようにしました。

初期値は「増やす」です。通常はこれまでと同じ感覚で時間を追加でき、修正が必要な場合だけ「減らす」を選択します。

延長履歴にも増減を残す

追加・減算した時間は履歴として残します。たとえば「2分00秒 + 1分00秒 - 30秒」のように表示し、その下へ現在の延長時間合計を表示します。

これなら、途中で時間を修正しても、何を変更したのか確認できます。

残り時間以上は減らせない

時間を減らす処理では、現在の残り時間を超えて減らそうとした場合にエラーを表示します。

残り時間より大きな時間を減らすことはできません。誤入力でタイマーが不自然な状態にならないよう、減算前にチェックしています。

また、現在の延長時間合計を超えて減らす操作もエラーにしています。

今回新しく使った主なHTML・JavaScript

fieldsetとlegend

<fieldset>は、関連する入力項目をひとまとまりとして扱うためのHTML要素です。今回は「累積時間/個別時間」や「増やす/減らす」のラジオボタンをグループ化するために使っています。

<legend>は、そのグループが何を選ぶ項目なのかを示す見出しです。

every()

every()は、配列内のすべての要素が条件を満たしているか確認できます。今回は、その段にある通常タイマーがすべて終了しているかを確認するために使っています。

一つでも終了していないタイマーがあればfalse、すべて終了していればtrueになります。

setTimeout()とclearTimeout()

setTimeout()で全体終了表示を1秒遅らせています。一方、リセットなどで待機中の処理が不要になった場合は、clearTimeout()で予約した処理を取り消します。

これにより、リセット後に突然「終了」が表示されるような誤動作を防いでいます。

第7回の動作確認

今回も、短い時間を設定して動作を確認しました。

  • 上段を累積時間、下段を個別時間にして正しく動作するか
  • 上段・下段をそれぞれ個別時間にして同時スタートできるか
  • 上段の通常タイマーがすべて終了したら上段全体が回転するか
  • 下段の通常タイマーがすべて終了したら下段全体が回転するか
  • 最後の段が終了してから約1秒後に全体終了が表示されるか
  • 全体終了が約1.5秒でフェードインするか
  • 個別延長タイマーが残っていても通常試験の全体終了が表示されるか
  • 終了済みの個別延長へ追加延長できるか
  • 「減らす」を選んで正しく時間を減算できるか
  • 残り時間以上を減らそうとするとエラーになるか
  • 全体リセット後に終了表示が残らないか

実際の画面を見ながら、段終了時の枠が目立ちすぎないように調整し、全体終了表示も上段と下段の中央付近へ出るように修正しました。

画面表示、スリープ防止など、実運用向けの仕上げも候補として検討していきます。

第7回 完成版を別画面で開く

今回のまとめ

第7回では、タイマーの時間表示方法と、試験終了時の画面表示を大きく改善しました。

  • 段ごとに累積時間/個別時間を選択できるようになった
  • 上段・下段の通常タイマーが終わると、段全体で終了を表示できるようになった
  • 上下段が終わった1秒後に、全体終了を1.5秒かけてフェード表示するようになった
  • 個別延長は通常試験終了後もそのまま使用できる
  • 個別延長時間を増やすだけでなく、入力ミス時には減らせるようになった

ここまでで、実際の試験会場で「今どの状態なのか」がかなり分かりやすくなってきました。

次回以降は、外観テーマや時計フォントの切り替え、保存・復元、全画面表示、スリープ防止など、実運用向けの仕上げも候補として検討していきます。