Day01|集中タイマーを作りながら学ぶ時間管理・状態管理の基礎【JavaScript初心者向け】

JavaScriptを学び始めたものの、「何を作ればいいかわからない」「作っても仕組みが理解できない」と感じていませんか。
この記事では、ブラウザだけで動く Mini Focus Timer(集中タイマー) を作りながら、JavaScriptの基礎となる時間管理・状態管理・保存処理を学びます。

※ 本記事のアプリは 静的HTML1枚で動作 します。
実際に動くデモは GitHub Pages app-001-focus-timerで公開しています。


この記事で解決できる悩み

  • JavaScript初心者で、最初に作る題材が決められない
  • setIntervalを使っているが、なぜ動くのか説明できない
  • 作業中・休憩中といった状態の切り替えが理解できない
  • LocalStorageの保存と復元の流れがつかめない
  • 環境構築で詰まらず、ブラウザ完結でアプリを作りたい

なぜ最初に集中タイマーを作るのか

集中タイマーは、JavaScript初心者が学ぶべき重要な要素を、無理なく1つのアプリにまとめられる題材です。

  • setIntervalによる時間管理を自然に理解できる
  • 実行中/停止中、作業/休憩といった状態管理を学べる
  • LocalStorageを使った設定保存を実践できる
  • HTML1枚構成なので、そのままGitHub Pagesで公開できる

「完成させること」ではなく、「仕組みを説明できること」を目的に進めます。


完成アプリの仕様(できること一覧)

  • アプリ名:Mini Focus Timer
  • モード切替:25分作業/5分休憩、50分作業/10分休憩、カスタム
  • 開始・一時停止・リセット操作
  • SVGによる進捗リング表示
  • 作業/休憩の状態表示
  • 音による通知(ON/OFF切替)
  • キーボード操作
    • Space:開始/一時停止
    • R:リセット
  • LocalStorageによる設定保存と復元
  • aria-liveによる読み上げ対応

仕組みを分解して理解する

setIntervalで1秒ずつ減らす考え方

タイマーは「分」ではなく「秒」で管理します。
1秒ごとに処理を実行し、残り秒数を減らしていくことで、計算と表示がシンプルになります。

分秒表示(mm:ss)の作り方

残り秒数を分と秒に分解し、padStart(2,'0') を使って2桁表示に整えます。

状態(focus / break)を持つ理由

タイマーは常に「作業」か「休憩」のどちらかです。
状態を変数として持つことで、時間設定・表示・切り替え処理が整理されます。

進捗リングの仕組み

SVGの円周を基準に、進捗率を stroke-dashoffset で表現します。
時間の進行を「割合」に変換し、視覚的に進捗を示しています。

二重スタート防止

開始ボタンの連打でsetIntervalが多重起動しないよう、
running フラグと timerId を使って制御しています。

LocalStorageによる保存と復元

設定値をオブジェクトとしてまとめ、JSON形式で保存・復元します。
ページを再読み込みしても、前回の設定が維持されます。

キーボード操作の注意点

Spaceキーはブラウザのスクロール動作と競合するため、
e.preventDefault() を使って既定動作を防いでいます。

音通知とブラウザ制約

WebAudioは、ユーザー操作後でないと再生できない場合があります。
チェックON時のみ音を鳴らす設計にしています。

aria-liveによる読み上げ

作業終了・休憩終了を音声でも伝えるため、aria-liveを使用しています。


実装(HTML / CSS / JavaScript)

以下が 完成版コード全文 です。
このコードは 表示用 です。実行する場合は、index.html として保存してください。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Day01 — Mini Focus Timer</title>
  <meta name="description" content="1分で使える集中タイマー。25分/5分を切替、進捗リング、音でお知らせ。GitHub Pages/Vercelにそのままデプロイ可。" />
  <style>
    :root{
      --bg:#0b1020; --panel:#121836; --text:#e9ecf1; --muted:#9aa3b2; --accent:#7aa2ff; --accent-2:#6efacc;
      --ring:#2a3566; --ring-active:#7aa2ff; --shadow: 0 10px 30px rgba(0,0,0,.35);
    }
    *{box-sizing:border-box}
    html,body{height:100%}
    body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Noto Sans JP",sans-serif;background:radial-gradient(1200px 600px at 80% -10%,#16204a,transparent), var(--bg); color:var(--text); display:grid; place-items:center;}
    .app{width:min(94vw,560px); background:linear-gradient(180deg, rgba(255,255,255,.03), transparent), var(--panel); border:1px solid rgba(255,255,255,.06); border-radius:18px; box-shadow:var(--shadow); padding:20px 20px 28px;}
    header{display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px}
    h1{font-size:18px; margin:0; letter-spacing:.2px}
    .sub{color:var(--muted); font-size:12px}
    .controls{display:flex; gap:10px; flex-wrap:wrap}
    .select, .input{appearance:none; background:#0f1430; color:var(--text); border:1px solid #232b55; border-radius:12px; padding:10px 14px; font-size:14px}
    .btn{border:0; border-radius:14px; padding:12px 16px; font-weight:600; cursor:pointer; transition:transform .05s ease;}
    .btn:active{transform:translateY(1px)}
    .btn-primary{background:linear-gradient(180deg, var(--accent), #557aff); color:#071022}
    .btn-ghost{background:#0d1330; color:var(--text); border:1px solid #2a3566}
    .grid{display:grid; grid-template-columns:1fr; gap:18px;}
    .ring{display:grid; place-items:center; aspect-ratio:1/1; width:min(80vw,420px); margin:8px auto 0; position:relative}
    .time{position:absolute; text-align:center}
    .time .mmss{font-variant-numeric:tabular-nums; font-size:46px; font-weight:700; letter-spacing:.5px}
    .time .label{margin-top:6px; font-size:12px; color:var(--muted)}
    footer{margin-top:16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--muted); font-size:12px}
    .kbd{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background:#0f1430; border:1px solid #28305b; padding:2px 6px; border-radius:6px;}
    .row{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
    .a11y{position:absolute; left:-9999px}
    a{color:var(--accent-2)}
  </style>
</head>
<body>
  <main class="app" role="application" aria-labelledby="app-title">
    <header>
      <div>
        <h1 id="app-title">Mini Focus Timer</h1>
        <div class="sub">Day01 — 静的Webアプリ / キーボード <span class="kbd">Space</span> で開始/一時停止</div>
      </div>
      <div class="controls" aria-label="設定">
        <select id="mode" class="select" aria-label="モード">
          <option value="25-5">25分作業 / 5分休憩</option>
          <option value="50-10">50分作業 / 10分休憩</option>
          <option value="custom">カスタム</option>
        </select>
        <input id="focusMin" class="input" type="number" min="1" max="180" step="1" value="25" aria-label="作業分" title="作業分">
        <input id="breakMin" class="input" type="number" min="1" max="60" step="1" value="5" aria-label="休憩分" title="休憩分">
      </div>
    </header>

    <section class="grid">
      <div class="ring" aria-live="polite" aria-atomic="true">
        <svg viewBox="0 0 120 120" width="100%" height="100%" role="img" aria-label="進捗リング">
          <circle cx="60" cy="60" r="54" fill="none" stroke="var(--ring)" stroke-width="8" />
          <circle id="progress" cx="60" cy="60" r="54" fill="none" stroke="var(--ring-active)" stroke-width="8" stroke-linecap="round" stroke-dasharray="339.292" stroke-dashoffset="339.292" transform="rotate(-90 60 60)" />
        </svg>
        <div class="time">
          <div class="mmss" id="mmss" aria-live="polite">25:00</div>
          <div class="label" id="phaseLabel">作業</div>
        </div>
      </div>

      <div class="row" aria-label="操作">
        <button id="startPause" class="btn btn-primary" aria-pressed="false">開始</button>
        <button id="reset" class="btn btn-ghost">リセット</button>
        <label class="row" style="gap:6px; color:var(--muted)">
          <input type="checkbox" id="soundToggle" /> 音でお知らせ
        </label>
      </div>
    </section>

    <footer>
      <div>保存先: LocalStorage(前回の設定を保持)</div>
      <div>ショートカット: <span class="kbd">Space</span> 開始/停止 ・ <span class="kbd">R</span> リセット</div>
    </footer>

    <div class="a11y" aria-live="assertive" id="a11y"></div>
  </main>

  <script>
  (function(){
    // ---- State ----
    const $ = (s)=>document.querySelector(s);
    const mmssEl = $('#mmss');
    const phaseEl = $('#phaseLabel');
    const progressEl = $('#progress');
    const startBtn = $('#startPause');
    const resetBtn = $('#reset');
    const modeSel = $('#mode');
    const focusInput = $('#focusMin');
    const breakInput = $('#breakMin');
    const soundToggle = $('#soundToggle');
    const a11yLive = $('#a11y');

    const CIRC = 2 * Math.PI * 54; // r=54
    progressEl.setAttribute('stroke-dasharray', String(CIRC));

    let timerId = null;
    let running = false;
    let phase = 'focus'; // 'focus' | 'break'
    let remainSec = 25*60; // seconds
    let totalSec = remainSec;

    // ---- Persistence ----
    const saved = JSON.parse(localStorage.getItem('mini-focus-timer')||'{}');
    if(saved.focus){ focusInput.value = saved.focus }
    if(saved.break){ breakInput.value = saved.break }
    if(saved.mode){ modeSel.value = saved.mode }
    if(saved.sound){ soundToggle.checked = !!saved.sound }

    // Apply mode -> inputs
    function applyMode(){
      const v = modeSel.value;
      if(v === '25-5'){ focusInput.value = 25; breakInput.value = 5; }
      else if(v === '50-10'){ focusInput.value = 50; breakInput.value = 10; }
      // custom: keep as is
      saveState();
      if(!running){ setPhase('focus'); }
    }

    function saveState(){
      localStorage.setItem('mini-focus-timer', JSON.stringify({
        focus: Number(focusInput.value),
        break: Number(breakInput.value),
        mode: modeSel.value,
        sound: soundToggle.checked
      }));
    }

    function clampInputs(){
      const f = Math.min(180, Math.max(1, Number(focusInput.value||25)));
      const b = Math.min(60, Math.max(1, Number(breakInput.value||5)));
      focusInput.value = f; breakInput.value = b;
    }

    function setPhase(next){
      phase = next;
      const minutes = (phase === 'focus') ? Number(focusInput.value) : Number(breakInput.value);
      totalSec = Math.round(minutes * 60);
      remainSec = totalSec;
      phaseEl.textContent = (phase === 'focus') ? '作業' : '休憩';
      updateUI();
    }

    function updateUI(){
      const m = Math.floor(remainSec/60).toString().padStart(2,'0');
      const s = Math.floor(remainSec%60).toString().padStart(2,'0');
      mmssEl.textContent = `${m}:${s}`;
      const ratio = 1 - (remainSec/Math.max(1,totalSec));
      progressEl.style.strokeDashoffset = String(CIRC * ratio);
      startBtn.textContent = running ? '一時停止' : '開始';
      startBtn.setAttribute('aria-pressed', String(running));
    }

    function beep(){
      if(!soundToggle.checked) return;
      try{
        const ctx = new (window.AudioContext||window.webkitAudioContext)();
        const o = ctx.createOscillator();
        const g = ctx.createGain();
        o.type = 'sine';
        o.frequency.setValueAtTime(880, ctx.currentTime);
        g.gain.setValueAtTime(0.001, ctx.currentTime);
        g.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + 0.02);
        g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35);
        o.connect(g).connect(ctx.destination);
        o.start();
        o.stop(ctx.currentTime + 0.4);
      }catch(e){/* ignore */}
    }

    function announce(msg){ a11yLive.textContent = msg; }

    function tick(){
      if(!running) return;
      remainSec -= 1;
      if(remainSec <= 0){
        beep();
        // Switch phase
        if(phase === 'focus'){
          announce('作業終了。休憩を開始します');
          setPhase('break');
        }else{
          announce('休憩終了。作業を開始します');
          setPhase('focus');
        }
      }
      updateUI();
    }

    function start(){
      if(running) return;
      running = true;
      updateUI();
      timerId = setInterval(tick, 1000);
    }

    function pause(){
      running = false;
      if(timerId) clearInterval(timerId);
      updateUI();
    }

    function reset(){
      pause();
      setPhase('focus');
    }

    // ---- Events ----
    startBtn.addEventListener('click', ()=>{ running ? pause() : start(); });
    resetBtn.addEventListener('click', reset);
    modeSel.addEventListener('change', ()=>{ applyMode(); });
    [focusInput, breakInput, soundToggle].forEach(el=>{
      el.addEventListener('change', ()=>{ clampInputs(); saveState(); if(!running) setPhase(phase); });
    });

    window.addEventListener('keydown', (e)=>{
      if(e.code === 'Space'){ e.preventDefault(); running ? pause() : start(); }
      if(e.key.toLowerCase() === 'r'){ reset(); }
    });

    // ---- Init ----
    clampInputs();
    applyMode();
    setPhase('focus');
    updateUI();
  })();
  </script>
</body>
</html>

実際に動くデモは GitHub Pages app-001-focus-timerで公開しています。


よくある失敗と対処法

開始を連打するとタイマーが加速する

原因:setIntervalの多重起動
対処:runningフラグで開始処理を制御する

一時停止しても裏で動いている

原因:clearIntervalを呼んでいない
対処:pause時に必ずclearIntervalを実行する

入力値がNaNになり表示が崩れる

原因:空文字や不正値
対処:最小値・最大値で入力を丸める

進捗リングが逆に動く

原因:進捗率の計算ミス
対処:残り時間と合計時間の式を確認する

Spaceキーで画面がスクロールする

原因:ブラウザの既定動作
対処:e.preventDefault() を使う

音が鳴らない

原因:AudioContextの再生制約
対処:ユーザー操作後に再生する


発展アイデア

  • 通知音の種類切替
  • ダーク/ライトテーマの切替
  • 作業ログの保存
  • タスク名の追加
  • 集中回数の可視化

まとめ(学びの整理)

  • setIntervalで時間を管理する基本
  • 秒管理から分秒表示への変換
  • 状態(running / focus / break)の考え方
  • SVGで進捗を可視化する方法
  • LocalStorageで設定を保存・復元する流れ
  • キーボード操作とアクセシビリティへの配慮

次に読む記事

次回は マッチングゲーム(神経衰弱) を作ります。
今回学んだ「状態管理」「イベント処理」「LocalStorage保存」を、カード操作・ロック処理・ベストタイム保存へと発展させていきます。

タイトルとURLをコピーしました