このシリーズについて
前回の第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をブラウザで開き、最初に設定画面が表示されることを確認します。
- 画面タイトルを入力する
- 上段タイトルとタイマー数を設定する
- 下段タイトルとタイマー数を設定する
- 各タイマーの名前と所要時間を入力する
- 「タイマーボードを作成」を押す
- 表示時間が段ごとに累積されていることを確認する
- 「全体スタート」を押す
- タイマーが独立して終了することを確認する
- 「終了」を押し、確認画面から設定画面へ戻る
- 入力内容が残っていることを確認する
秒は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回の完成版を埋め込みます。(別画面で開いたほうが快適です)
今回のまとめ
- 最初に設定画面を表示するようにした
- 一番上の画面タイトルを変更できるようにした
- 上段は1~4個、下段は0~4個に対応した
- 必要な数だけ入力欄とタイマーパネルを自動生成した
- 段ごとに所要時間を累積して表示した
- 下段0個では下段全体を非表示にした
- 設定画面へ戻っても入力内容を残した
- 本番画面の操作を「全体スタート」と「終了」へ整理した
- 上段を青、下段を紫の薄い背景で区別した

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

次回は、マシントラブルなどで試験時間を延長する人が出た場合に使う、個別の追加タイマーを作る予定だよ。
AIと共同制作しています
このシリーズでは、必要な機能、使い方、画面の動き、修正内容をきょろぽんが考え、HTML・CSS・JavaScriptのソースコードをAIのノンが作成しています。
完成したコードはそのまま使うだけでなく、実際にブラウザで動かし、表示や操作を確認しながら修正しています。人が目的と判断を担当し、AIがコード作成を担当する、共同制作の記録です。





