第5回 試験用キッチンタイマー制作|可変タイマー・累積時間編

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

前回の第4回では、上段4個・下段3個のタイマーを1画面に並べ、まとめてスタートできるようにしました。

ただ、実際の試験では毎回同じ個数のタイマーを使うとは限りません。試験によって課題数が違ったり、上段だけ使いたかったり、2級と3級で別々の時間を表示したかったりします。

そこで第5回では、最初に設定画面を表示し、必要なタイマーだけを自動生成できるようにします。さらに、各課題へ入力した時間を段ごとに累積し、実際の終了時刻として表示できるようにしました。

きょろぽん
きょろぽん

毎回、必要な数だけ自動で並べられたら楽なんだけど、できるのかな?

ノン
ノン

設定画面を作って、入力内容に応じてHTMLを自動生成すればできるよ。今回は累積時間も一緒に計算しよう。

今回追加する機能

  • 一番上に表示する画面タイトルを設定できる
  • 上段タイトルと下段タイトルを設定できる
  • 上段は1~4個、下段は0~4個から選べる
  • タイマー名と各課題の所要時間を入力できる
  • 入力時間を上段・下段ごとに累積して表示する
  • 下段を0個にすると、下段全体を非表示にする
  • 設定画面へ戻っても入力内容を残す
  • 本番画面では時間変更用の三角ボタンを表示しない
  • 上段はうっすら青、下段はうっすら紫の背景で区別する
  • 操作ボタンを「全体スタート」と「終了」の2個に整理する

タイマーへ入力するのは各課題に必要な時間です。画面へ表示する時間は、上段と下段でそれぞれ自動的に累積します。

累積時間の考え方

たとえば上段へ、次のように入力したとします。

  • 課題1:10分
  • 課題2:10分
  • 課題3:30分

タイマーボードには、10分、20分、50分と表示されます。

課題2の終了時刻は、課題1の10分と課題2の10分を合計した20分後です。課題3は、すべてを合計した50分後になります。

上段と下段は別々に計算するため、上段の最後の時間が下段へ加算されることはありません。

第4回のフォルダをコピーする

今回も、前回の完成版を残したまま新しい回を作ります。

VSCodeで第4回フォルダをコピーし、フォルダ名を「05」に変更します。

KITCHEN-TIMER
├─ 04
│  ├─ index.html
│  ├─ style.css
│  └─ script.js
└─ 05
   ├─ index.html
   ├─ style.css
   └─ script.js

前回のフォルダを直接変更せず、毎回番号フォルダをコピーしています。これで、第1回から各回の完成状態をそのまま残せます。

HTMLを変更する

第5回のHTMLでは、設定画面と本番画面の2つを用意します。

ページを開いた直後は設定画面を表示し、「タイマーボードを作成」を押した後で本番画面へ切り替えます。

また、タイマーの入力欄と本番用パネルは、どちらも template を使って必要な数だけ生成します。

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>
            </div>
          </div>

          <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>
            </div>
          </div>

          <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>
        <p>設定内容に応じて自動生成された複数タイマー</p>
      </header>

      <section id="upper-timer-section" class="timer-section timer-section-upper" aria-labelledby="upper-heading">
        <div class="section-heading">
          <h2 id="upper-heading">上段タイトル</h2>
        </div>
        <div id="upper-timer-grid" class="timer-grid"></div>
      </section>

      <section id="lower-timer-section" class="timer-section timer-section-lower" aria-labelledby="lower-heading">
        <div class="section-heading">
          <h2 id="lower-heading">下段タイトル</h2>
        </div>
        <div id="lower-timer-grid" class="timer-grid"></div>
      </section>

      <div class="overall-controls">
        <button id="start-all-button" class="primary-button start-all-button" type="button">
          ▶ 全体スタート
        </button>
        <button id="finish-button" class="secondary-button finish-button" type="button">
          終了
        </button>
      </div>
    </section>
  </main>

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

        <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>
      </div>
    </article>
  </template>

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

CSSを変更する

CSSでは、設定画面の入力欄、本番画面の可変グリッド、タイマー名と数字の大きさを調整しています。

本番画面では時間を変更しないため、第4回まで使っていた上下の三角ボタンは外しました。その分、分と秒の数字を大きくし、半角の「:」を使って横幅を節約しています。

上段と下段は、タイマーパネルの色を変えず、段全体の背景だけを少し変えています。上段は青、下段は紫をうっすら重ねました。

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

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

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background: radial-gradient(circle at center, #17243a 0%, #070b12 60%, #000 100%);
  color: #f7f9ff;
}

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, 0.5);
  border-radius: 12px;
  background: rgba(8, 17, 31, 0.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, 0.2);
}

.app-shell {
  width: min(1920px, 100%);
  min-height: 100vh;
  margin: 0 auto;
  padding: clamp(14px, 2vw, 32px);
}

.screen[hidden] {
  display: none;
}

.page-header {
  margin-bottom: clamp(20px, 2vw, 34px);
  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(0.9rem, 1.2vw, 1.15rem);
}

.setup-form {
  display: grid;
  gap: clamp(20px, 2.4vw, 34px);
}

.setup-group {
  padding: clamp(16px, 2vw, 28px);
  border: 1px solid rgba(151, 190, 245, 0.35);
  border-radius: 22px;
  background: linear-gradient(145deg, rgba(23, 39, 63, 0.88), rgba(7, 13, 24, 0.93));
  box-shadow: 0 0 28px rgba(85, 139, 226, 0.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;
}

.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, 0.22);
  border-radius: 16px;
  background: rgba(5, 12, 23, 0.48);
}

.duration-fields {
  display: grid;
  grid-template-columns: repeat(2, minmax(90px, 110px));
  gap: 10px;
}

.setup-error {
  min-height: 1.5em;
  margin: -10px 0 0;
  color: #ff9b9b;
  font-weight: 700;
  text-align: center;
}

.setup-actions {
  display: flex;
  justify-content: center;
  padding-bottom: 20px;
}

.primary-button,
.secondary-button {
  min-height: 56px;
  padding: 11px 24px;
  border: 1px solid #84b6ff;
  border-radius: 15px;
  color: #eef5ff;
  font-weight: 700;
}

.primary-button {
  background: linear-gradient(145deg, #376cff, #1541b8);
  box-shadow: 0 0 18px rgba(71, 126, 255, 0.35), inset 0 0 10px rgba(255, 255, 255, 0.1);
}

.secondary-button {
  border-color: rgba(151, 184, 227, 0.58);
  background: rgba(21, 34, 54, 0.92);
  box-shadow: inset 0 0 10px rgba(122, 169, 236, 0.08);
}

.primary-button:hover:not(:disabled),
.primary-button:focus-visible:not(:disabled),
.secondary-button:hover,
.secondary-button:focus-visible {
  filter: brightness(1.14);
  outline: 3px solid rgba(155, 198, 255, 0.32);
  outline-offset: 3px;
}

.primary-button:disabled {
  cursor: not-allowed;
  opacity: 0.35;
}

.create-board-button {
  width: min(440px, 100%);
  font-size: clamp(1.05rem, 1.7vw, 1.45rem);
}

.timer-section {
  margin-bottom: clamp(24px, 3vw, 44px);
}

.section-heading {
  margin-bottom: 12px;
}

.section-heading h2 {
  font-size: clamp(1.8rem, 3vw, 3rem);
  line-height: 1.15;
  overflow-wrap: anywhere;
}

.timer-grid {
  display: grid;
  grid-template-columns: repeat(var(--timer-count, 4), minmax(0, 1fr));
  gap: clamp(10px, 1.2vw, 22px);
}

.timer-panel {
  min-width: 0;
  perspective: 1200px;
  transition: opacity 0.35s ease;
}

.timer-panel-inner {
  display: grid;
  width: 100%;
  min-height: clamp(250px, 22vw, 390px);
  transform-style: preserve-3d;
  transition: transform 0.7s ease-in-out;
}

.timer-panel.is-finished {
  opacity: 0.7;
}

.timer-panel.is-finished .timer-panel-inner {
  transform: rotateY(180deg);
}

.timer-face {
  grid-area: 1 / 1;
  display: grid;
  width: 100%;
  padding: clamp(12px, 1.4vw, 24px);
  border: 1px solid rgba(182, 211, 255, 0.55);
  border-radius: clamp(18px, 2vw, 32px);
  background: linear-gradient(145deg, rgba(32, 50, 78, 0.92), rgba(6, 12, 23, 0.96));
  box-shadow: 0 0 24px rgba(116, 168, 255, 0.16), inset 0 0 24px rgba(158, 200, 255, 0.08);
  text-align: center;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.timer-face-front {
  align-content: center;
  gap: clamp(16px, 2vw, 30px);
}

.timer-face-back {
  align-content: center;
  justify-items: center;
  transform: rotateY(180deg);
}

.timer-title {
  max-width: 100%;
  margin: 0;
  color: #eef5ff;
  font-size: clamp(1.55rem, 2.7vw, 3rem);
  font-weight: 800;
  line-height: 1.08;
  text-shadow: 0 0 16px rgba(159, 198, 255, 0.36);
  overflow-wrap: anywhere;
}

.time-display {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  gap: 0;
  width: 100%;
}

.minutes-display,
.seconds-display,
.time-colon {
  color: #fff;
  font-family: Consolas, "Roboto Mono", "Courier New", monospace;
  font-weight: 700;
  line-height: 0.9;
  text-shadow: 0 0 11px rgba(255, 255, 255, 0.52), 0 0 26px rgba(153, 194, 255, 0.22);
}

.minutes-display,
.seconds-display {
  min-width: 0;
  font-size: clamp(4rem, 8.3vw, 8.8rem);
  letter-spacing: -0.09em;
}

.time-colon {
  padding: 0 0.02em 0.08em;
  font-size: clamp(3rem, 5.2vw, 5.8rem);
}

.finished-message {
  display: grid;
  justify-items: center;
  gap: 10px;
  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, 0.4), 0 0 28px rgba(153, 194, 255, 0.24);
}

.finished-title {
  font-size: clamp(1.8rem, 3.3vw, 3.6rem);
}

.finished-text {
  font-size: clamp(3.5rem, 6.3vw, 7rem);
  letter-spacing: 0.08em;
}

.overall-controls {
  display: flex;
  justify-content: center;
  gap: 14px;
  padding-bottom: 18px;
}

.start-all-button,
.finish-button {
  min-width: min(320px, 46%);
  min-height: 66px;
  font-size: clamp(1.25rem, 2vw, 1.9rem);
}

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

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

  .minutes-display,
  .seconds-display {
    font-size: clamp(4.4rem, 13vw, 8rem);
  }
}

@media (max-width: 700px) {
  .setup-group-header,
  .timer-setting-row {
    grid-template-columns: 1fr;
  }

  .duration-fields {
    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);
  }

  .overall-controls {
    display: grid;
    grid-template-columns: 1fr;
  }

  .start-all-button,
  .finish-button {
    width: 100%;
    min-width: 0;
  }
}

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


.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, 0.9), rgba(7, 13, 24, 0.94));
}

/* 上段・下段を遠くから見分けやすくする薄い背景色 */
.timer-section-upper,
.timer-section-lower {
  padding: clamp(14px, 1.5vw, 24px);
  border: 1px solid rgba(166, 198, 241, 0.18);
  border-radius: clamp(18px, 2vw, 28px);
}

.timer-section-upper {
  background:
    linear-gradient(145deg, rgba(38, 88, 150, 0.14), rgba(7, 13, 24, 0.38));
  box-shadow:
    inset 0 0 32px rgba(74, 133, 219, 0.06),
    0 0 22px rgba(50, 104, 180, 0.06);
}

.timer-section-lower {
  background:
    linear-gradient(145deg, rgba(92, 57, 137, 0.14), rgba(7, 13, 24, 0.38));
  box-shadow:
    inset 0 0 32px rgba(133, 86, 190, 0.06),
    0 0 22px rgba(95, 59, 145, 0.06);
}

.timer-section[hidden],
.timer-settings-list[hidden] {
  display: none;
}

JavaScriptを変更する

第5回のJavaScriptでは、設定内容の保持、入力欄の動的生成、累積時間の計算、本番用タイマーの生成を行います。

設定内容を一時的に保持する

settingsStateへ、画面タイトル、段タイトル、タイマー数、タイマー名、分、秒を保存しています。

終了ボタンから設定画面へ戻っても、このデータは残っているため、入力ミスをした場所だけ修正できます。

今回はページを開いている間だけ設定を保持します。ブラウザを閉じたり再読み込みした後も保存する機能は、後の回でlocalStorageを使って追加する予定です。

タイマー数に応じて入力欄を作る

上段は1~4個、下段は0~4個です。選択された個数に合わせて、タイマー名・分・秒の入力欄を自動生成します。

下段を0個にすると、入力欄と本番画面の下段全体を非表示にします。ただし、以前入力していた下段の内容は削除せず保持します。

段ごとの累積時間を作る

makeCumulativeTimers()では、各タイマーの所要時間を順番に加算し、その時点までの合計時間を本番用タイマーへ渡しています。

終了ボタンで設定画面へ戻る

「終了」を押すと、次の確認を表示します。

設定画面へ戻ります。タイマーは終了します。よろしいですか?

OKを押した場合だけタイマーを停止し、設定画面へ戻ります。キャンセルを押せば、そのまま本番画面を使用できます。

script.js のソースコードを表示
const DEFAULT_MINUTES = 5;
const DEFAULT_SECONDS = 0;
const UPDATE_INTERVAL = 200;

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 lowerHeading = document.getElementById("lower-heading");
const boardTitle = document.getElementById("board-title");
const lowerTimerSection = document.getElementById("lower-timer-section");
const upperTimerGrid = document.getElementById("upper-timer-grid");
const lowerTimerGrid = document.getElementById("lower-timer-grid");
const timerPanelTemplate = document.getElementById("timer-panel-template");

const startAllButton = document.getElementById("start-all-button");
const finishButton = document.getElementById("finish-button");

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

let timers = [];

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 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) {
  const rows = [...container.querySelectorAll(".timer-setting-row")];

  rows.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;

  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);
    const label = row.querySelector(".timer-name-label");
    const nameInput = row.querySelector(".timer-name-input");
    const minutesInput = row.querySelector(".minutes-input");
    const secondsInput = row.querySelector(".seconds-input");

    label.textContent = `タイマー${index + 1}の名前`;
    nameInput.value = timerSetting.name;
    minutesInput.value = timerSetting.minutes;
    secondsInput.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 }, panelElement) {
    this.name = name;
    this.totalMilliseconds = totalMilliseconds;
    this.group = group;
    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.finishedTitle = panelElement.querySelector(".finished-title");

    this.titleElement.textContent = this.name;
    this.finishedTitle.textContent = this.name;
    this.updateDisplay(this.remainingMilliseconds);
  }

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

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

  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");
    updateStartButtonState();
  }

  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 nameInput = row.querySelector(".timer-name-input");
    const minutesInput = row.querySelector(".minutes-input");
    const secondsInput = row.querySelector(".seconds-input");

    const minutes = getInputInteger(minutesInput, 0);
    const seconds = getInputInteger(secondsInput, 0, 59);

    if (minutes === null) {
      return { error: `${groupKey === "upper" ? "上段" : "下段"}タイマー${index + 1}の分は、0以上の整数で入力してください。` };
    }

    if (seconds === null) {
      return { error: `${groupKey === "upper" ? "上段" : "下段"}タイマー${index + 1}の秒は、0~59の整数で入力してください。` };
    }

    if (minutes === 0 && seconds === 0) {
      return { error: `${groupKey === "upper" ? "上段" : "下段"}タイマー${index + 1}は、0分0秒では作成できません。` };
    }

    const fallbackNumber = groupKey === "upper"
      ? index + 1
      : settingsState.upper.count + index + 1;

    const name = nameInput.value.trim() || `タイマー${fallbackNumber}`;

    parsedTimers.push({
      name,
      minutes,
      seconds,
      durationMilliseconds: (minutes * 60 + seconds) * 1000
    });
  }

  return { timers: parsedTimers };
}

function makeCumulativeTimers(groupKey, parsedTimers) {
  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 renderBoard(upperTimers, lowerTimers) {
  timers.forEach((timer) => timer.stopAndDispose());
  timers = [];

  upperTimerGrid.replaceChildren();
  lowerTimerGrid.replaceChildren();

  upperTimerGrid.style.setProperty("--timer-count", String(upperTimers.length));
  lowerTimerGrid.style.setProperty("--timer-count", String(lowerTimers.length));

  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() || "下段タイトル";
  lowerTimerSection.hidden = lowerTimers.length === 0;

  updateStartButtonState();
}

function updateStartButtonState() {
  startAllButton.disabled = !timers.some((timer) => timer.canStart());
}

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

function showSetupScreen() {
  boardScreen.hidden = true;
  setupScreen.hidden = false;
  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((timer) => ({
    name: timer.name,
    minutes: timer.minutes,
    seconds: timer.seconds
  }));

  settingsState.lower.timers = lowerResult.timers.map((timer) => ({
    name: timer.name,
    minutes: timer.minutes,
    seconds: timer.seconds
  }));

  setupError.textContent = "";

  const upperCumulativeTimers = makeCumulativeTimers("upper", upperResult.timers);
  const lowerCumulativeTimers = makeCumulativeTimers("lower", lowerResult.timers);

  renderBoard(upperCumulativeTimers, lowerCumulativeTimers);
  showBoardScreen();
});

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

lowerCountSelect.addEventListener("change", () => {
  handleCountChange("lower", lowerCountSelect, lowerSettingsContainer);
});

startAllButton.addEventListener("click", () => {
  timers.filter((timer) => timer.canStart()).forEach((timer) => timer.start());
  updateStartButtonState();
});

finishButton.addEventListener("click", () => {
  const confirmed = window.confirm(
    "設定画面へ戻ります。タイマーは終了します。よろしいですか?"
  );

  if (!confirmed) {
    return;
  }

  timers.forEach((timer) => timer.stopAndDispose());
  showSetupScreen();
});

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

ブラウザで動作を確認する

index.htmlをブラウザで開き、最初に設定画面が表示されることを確認します。

  1. 画面タイトルを入力する
  2. 上段タイトルとタイマー数を設定する
  3. 下段タイトルとタイマー数を設定する
  4. 各タイマーの名前と所要時間を入力する
  5. 「タイマーボードを作成」を押す
  6. 表示時間が段ごとに累積されていることを確認する
  7. 「全体スタート」を押す
  8. タイマーが独立して終了することを確認する
  9. 「終了」を押し、確認画面から設定画面へ戻る
  10. 入力内容が残っていることを確認する

上段を青、下段を紫で表示した本番画面

下段を0個に設定して上段だけ表示した画面

秒は0~59の整数で入力します。0分0秒のタイマーは作成できません。入力に問題がある場合は、設定画面の下へエラーメッセージを表示します。

Xserverへ第5回を公開する

動作確認が終わったら、Xserverのファイルマネージャーを開きます。

/public_html/tools/kitchen-timer/の中へ「05」フォルダを作り、次の3ファイルをアップロードします。

  • index.html
  • style.css
  • script.js

公開先は次のURLです。

https://nonbiri-lab.jp/tools/kitchen-timer/05/

完成版を記事へ埋め込む

記事の最後へ、第5回の完成版を埋め込みます。(別画面で開いたほうが快適です)

第5回の試験用キッチンタイマーを別画面で開く

今回のまとめ

  • 最初に設定画面を表示するようにした
  • 一番上の画面タイトルを変更できるようにした
  • 上段は1~4個、下段は0~4個に対応した
  • 必要な数だけ入力欄とタイマーパネルを自動生成した
  • 段ごとに所要時間を累積して表示した
  • 下段0個では下段全体を非表示にした
  • 設定画面へ戻っても入力内容を残した
  • 本番画面の操作を「全体スタート」と「終了」へ整理した
  • 上段を青、下段を紫の薄い背景で区別した
きょろぽん
きょろぽん

これで試験ごとに必要な数を設定できるね。2級の人は青い上段、3級の人は紫の下段を見てね、と案内もしやすくなった。

ノン
ノン

次回は、マシントラブルなどで試験時間を延長する人が出た場合に使う、個別の追加タイマーを作る予定だよ。

AIと共同制作しています

このシリーズでは、必要な機能、使い方、画面の動き、修正内容をきょろぽんが考え、HTML・CSS・JavaScriptのソースコードをAIのノンが作成しています。

完成したコードはそのまま使うだけでなく、実際にブラウザで動かし、表示や操作を確認しながら修正しています。人が目的と判断を担当し、AIがコード作成を担当する、共同制作の記録です。