見出し画像

VBAのタイマーを「モダンなWebアプリ」にリファクタリングしてみた【ソースコード公開】


業務でちょっとした時間を計りたいとき、Excel VBAでサクッとタイマーを作ったことはありませんか? VBAは手軽で強力ですが、「スマホで見られない」「Macやタブレットで動かない」「見た目が地味」といった悩みもつきものです。
今回は、「Excel VBAで作られたタイマーロジック」を、ブラウザだけで動く「モダンなWebアプリ(SPA)」に完全移植してみました。
単なる翻訳ではなく、Chart.jsによる可視化やPDF出力機能も盛り込んでいます。HTMLファイル1つで動くので、ぜひ試してみてください。
📱 完成イメージ
今回作成したのは、以下の機能を備えたWebタイマーです。
時間設定自由: 分・秒を自由に変更可能。視覚的カウントダウン: 円グラフで残り時間を可視化(Chart.js)。アラーム音: 時間が来たら「ピピピッ」とお知らせ(Web Audio API)。記録の保存: 現在の画面をPDFとしてダウンロード(html2canvas + jsPDF)。完全レスポンシブ: PCでもスマホでも動作可能。🔄 VBAとJavaScriptの考え方の違い
移植にあたり、プロのWeb開発者の視点でロジックを再設計(リファクタリング)しました。
状態管理:
VBAでは「セルの値」を読み書きして時間を管理していましたが、Webアプリでは「JavaScriptのクラス(変数)」で状態を管理し、表示のみをDOMに反映させるMVCに近い設計に変更しました。非同期処理:
VBAの Application.OnTime の代わりに、JavaScriptの setInterval を使用し、ブラウザをブロックせずにスムーズに動作させます。外部ライブラリの活用:グラフ描画やPDF化は、ゼロから作るのではなく優秀なOSSライブラリ(CDN)を活用して実装コストを下げています。💻 ソースコード(コピペOK)
末尾のコードをメモ帳などに貼り付け、timer.html という名前で保存してください。それをブラウザ(ChromeやEdge)で開くだけで動きます。

🛠️ 工夫したポイント
残り時間が少なくなると赤くなるChart.jsの設定を動的に書き換え、残り20%を切ると色が変化する演出を入れました。電子音の生成音声ファイルを用意しなくてもいいように、ブラウザの「Web Audio API」を使って、プログラムで「ピッ」という音を作っています。PDF化の裏側画面上のHTML要素を一度画像(Canvas)に変換し、それをPDFに貼り付ける処理を html2canvas と jsPDF で実現しています。まとめ
VBAは素晴らしいツールですが、Web技術(HTML/CSS/JS)を使えば、OSを問わず配布しやすいリッチなアプリケーションが作れます。 「業務効率化ツールを自作したい!」という方は、ぜひこのコードを改造して遊んでみてください。


以下 コード

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Configurable Web Timer</title>
    
    <!-- 外部ライブラリ (Chart.js, HTML2Canvas, jsPDF) -->
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>

    <style>
        :root {
            --primary-color: #007bff;
            --danger-color: #dc3545;
            --secondary-color: #6c757d;
            --bg-color: #f0f2f5;
            --input-border: #ced4da;
        }

        body {
            font-family: 'Helvetica Neue', Arial, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            margin: 0;
            background-color: var(--bg-color);
            color: #333;
        }

        .container {
            background: white;
            padding: 2.5rem;
            border-radius: 20px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.1);
            text-align: center;
            max-width: 400px;
            width: 90%;
        }

        /* グラフと時間の表示エリア */
        #capture-area {
            position: relative;
            margin-bottom: 20px;
            padding: 10px;
            background: white;
        }

        .chart-container {
            position: relative;
            height: 280px;
            width: 280px;
            margin: 0 auto;
        }

        .timer-display {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 3.5rem;
            font-weight: 700;
            color: #2c3e50;
            font-feature-settings: "tnum"; /* 等幅数字 */
        }

        /* 設定エリア */
        .settings-area {
            display: flex;
            justify-content: center;
            align-items: center;
            gap: 10px;
            margin-bottom: 20px;
            padding: 10px;
            background-color: #f8f9fa;
            border-radius: 10px;
        }

        .input-group {
            display: flex;
            align-items: center;
            gap: 5px;
        }

        input[type="number"] {
            width: 60px;
            padding: 8px;
            font-size: 1.2rem;
            text-align: center;
            border: 1px solid var(--input-border);
            border-radius: 5px;
        }

        input:disabled {
            background-color: #e9ecef;
            color: #6c757d;
        }

        /* コントロールボタン */
        .controls {
            display: grid;
            grid-template-columns: 1fr 1fr 1fr;
            gap: 12px;
            margin-bottom: 20px;
        }

        button {
            padding: 12px;
            font-size: 1rem;
            font-weight: 600;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.2s;
            color: white;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }

        button:active { transform: translateY(1px); box-shadow: none; }
        button:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }

        #btn-start { background-color: var(--primary-color); }
        #btn-start:hover { background-color: #0056b3; }

        #btn-stop  { background-color: var(--danger-color); }
        #btn-stop:hover { background-color: #a71d2a; }

        #btn-reset { background-color: var(--secondary-color); }
        #btn-reset:hover { background-color: #545b62; }

        /* PDFボタン */
        .pdf-section {
            border-top: 2px dashed #e9ecef;
            padding-top: 20px;
        }
        
        #btn-pdf {
            background-color: #28a745;
            width: 100%;
        }
        #btn-pdf:hover { background-color: #218838; }

    </style>
</head>
<body>

    <div class="container">
        <!-- PDF化対象エリア -->
        <div id="capture-area">
            <h2 style="margin-top:0; color:#444;">Timer</h2>
            <div class="chart-container">
                <canvas id="timerChart"></canvas>
                <div id="timerValue" class="timer-display">05:00</div>
            </div>
        </div>

        <!-- 時間設定エリア -->
        <div class="settings-area">
            <div class="input-group">
                <input type="number" id="set-min" min="0" max="99" value="5">
                <label>分</label>
            </div>
            <div class="input-group">
                <input type="number" id="set-sec" min="0" max="59" value="0">
                <label>秒</label>
            </div>
        </div>

        <!-- コントロールボタン -->
        <div class="controls">
            <button id="btn-start">START</button>
            <button id="btn-stop">STOP</button>
            <button id="btn-reset">RESET</button>
        </div>

        <!-- PDF出力 -->
        <div class="pdf-section">
            <button id="btn-pdf">記録をPDFで保存</button>
        </div>
    </div>

    <script>
        /**
         * TimerApp Class
         * ユーザー設定可能なタイマーロジック
         */
        class TimerApp {
            constructor() {
                // DOM要素の取得
                this.uiDisplay = document.getElementById('timerValue');
                this.inputMin = document.getElementById('set-min');
                this.inputSec = document.getElementById('set-sec');
                
                // 状態変数
                this.defaultSeconds = this.getInputTotalSeconds(); // 初期設定値
                this.remainingSeconds = this.defaultSeconds;
                this.timerId = null;
                this.isRunning = false;
                
                // Chart.js インスタンス
                this.chartInstance = null;

                // 初期化
                this.initChart();
                this.updateDisplay();
                this.toggleInputs(true); // 入力可能状態
            }

            // ==========================================
            // 設定値の計算ヘルパー
            // ==========================================
            getInputTotalSeconds() {
                const m = parseInt(this.inputMin.value, 10) || 0;
                const s = parseInt(this.inputSec.value, 10) || 0;
                return (m * 60) + s;
            }

            // 入力欄の有効/無効切り替え
            toggleInputs(isEnabled) {
                this.inputMin.disabled = !isEnabled;
                this.inputSec.disabled = !isEnabled;
            }

            // 設定変更時の反映
            applySettings() {
                if (this.isRunning) return; // 動作中は無視
                this.defaultSeconds = this.getInputTotalSeconds();
                this.remainingSeconds = this.defaultSeconds;
                this.updateDisplay();
            }

            // ==========================================
            // Chart.js 初期化
            // ==========================================
            initChart() {
                const ctx = document.getElementById('timerChart').getContext('2d');
                this.chartInstance = new Chart(ctx, {
                    type: 'doughnut',
                    data: {
                        labels: ['残り時間', '経過時間'],
                        datasets: [{
                            data: [this.remainingSeconds, 0],
                            backgroundColor: ['#007bff', '#e9ecef'],
                            borderWidth: 0
                        }]
                    },
                    options: {
                        responsive: true,
                        cutout: '85%',
                        plugins: { legend: { display: false }, tooltip: { enabled: false } },
                        animation: { duration: 0 }
                    }
                });
            }

            // ==========================================
            // 表示更新 (VBA: Range(TARGET_CELL).Value = ...)
            // ==========================================
            updateDisplay() {
                // MM:SS 形式に変換
                const minutes = Math.floor(this.remainingSeconds / 60).toString().padStart(2, '0');
                const seconds = (this.remainingSeconds % 60).toString().padStart(2, '0');
                this.uiDisplay.textContent = `${minutes}:${seconds}`;

                // グラフ更新
                if (this.chartInstance) {
                    const elapsed = Math.max(0, this.defaultSeconds - this.remainingSeconds);
                    this.chartInstance.data.datasets[0].data = [
                        this.remainingSeconds,
                        elapsed
                    ];
                    // グラフの色を残り時間に応じて変更(残り20%で赤くなる演出)
                    if (this.remainingSeconds / this.defaultSeconds <= 0.2 && this.remainingSeconds > 0) {
                        this.chartInstance.data.datasets[0].backgroundColor[0] = '#dc3545'; // 赤
                    } else {
                        this.chartInstance.data.datasets[0].backgroundColor[0] = '#007bff'; // 青
                    }
                    this.chartInstance.update();
                }
            }

            // ==========================================
            // 操作ロジック (Start/Stop/Reset)
            // ==========================================
            start() {
                if (this.isRunning) return;
                
                // 0秒ならリセットしてから開始
                if (this.remainingSeconds <= 0) {
                    this.applySettings(); // 現在の入力値を再適用
                }
                
                // 設定値が0の場合は動かさない
                if (this.remainingSeconds <= 0) return;

                this.isRunning = true;
                this.toggleInputs(false); // 入力ロック

                // 1秒ごとに減算
                this.timerId = setInterval(() => this.tick(), 1000);
            }

            stop() {
                if (this.timerId) {
                    clearInterval(this.timerId);
                    this.timerId = null;
                }
                this.isRunning = false;
                this.toggleInputs(true); // 入力解除
            }

            reset() {
                this.stop();
                this.applySettings(); // 入力値に戻す
            }

            // ==========================================
            // タイマー更新 (Tick)
            // ==========================================
            tick() {
                if (this.remainingSeconds > 0) {
                    this.remainingSeconds--;
                    this.updateDisplay();
                } else {
                    this.finish();
                }
            }

            // ==========================================
            // 終了処理
            // ==========================================
            finish() {
                this.stop();
                this.remainingSeconds = 0;
                this.updateDisplay();
                
                // 音を鳴らす
                this.playAlarmSequence();

                // 少し待ってからアラート
                setTimeout(() => {
                    alert("時間になりました!");
                }, 500);
            }

            // ==========================================
            // サウンド機能 (Web Audio API)
            // ==========================================
            playAlarmSequence() {
                // 3回鳴らす (ピッ、ピッ、ピッ)
                this.beep(0);
                setTimeout(() => this.beep(100), 400);
                setTimeout(() => this.beep(200), 800);
            }

            beep(delayVal) {
                const AudioContext = window.AudioContext || window.webkitAudioContext;
                if (!AudioContext) return;

                const ctx = new AudioContext();
                const osc = ctx.createOscillator();
                const gain = ctx.createGain();

                osc.connect(gain);
                gain.connect(ctx.destination);

                osc.type = 'sine'; // 正弦波
                osc.frequency.value = 880; // ラの音 (少し高め)

                // 音量エンベロープ(徐々に消える)
                gain.gain.setValueAtTime(0.1, ctx.currentTime);
                gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.3);

                osc.start(ctx.currentTime);
                osc.stop(ctx.currentTime + 0.3);
            }
        }

        // ==========================================
        // イベントハンドリング
        // ==========================================
        document.addEventListener('DOMContentLoaded', () => {
            const app = new TimerApp();

            // ボタン操作
            document.getElementById('btn-start').addEventListener('click', () => app.start());
            document.getElementById('btn-stop').addEventListener('click', () => app.stop());
            document.getElementById('btn-reset').addEventListener('click', () => app.reset());

            // 入力変更時に時間を即反映させる
            const inputs = [document.getElementById('set-min'), document.getElementById('set-sec')];
            inputs.forEach(input => {
                input.addEventListener('change', () => app.applySettings());
                input.addEventListener('keyup', () => app.applySettings());
            });

            // PDFダウンロード処理
            document.getElementById('btn-pdf').addEventListener('click', async () => {
                const btn = document.getElementById('btn-pdf');
                btn.disabled = true;
                btn.textContent = '生成中...';
                document.body.style.cursor = 'wait';

                try {
                    const element = document.getElementById('capture-area');
                    
                    // html2canvasの設定(高画質化)
                    const canvas = await html2canvas(element, { scale: 3 });
                    const imgData = canvas.toDataURL('image/png');

                    // A4 PDF作成
                    const { jsPDF } = window.jspdf;
                    const pdf = new jsPDF('p', 'mm', 'a4');
                    
                    const imgProps = pdf.getImageProperties(imgData);
                    const pdfWidth = pdf.internal.pageSize.getWidth();
                    const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;

                    pdf.addImage(imgData, 'PNG', 0, 30, pdfWidth, pdfHeight);
                    pdf.save(`timer_result_${new Date().getTime()}.pdf`);
                } catch (e) {
                    alert('PDF作成に失敗しました');
                    console.error(e);
                } finally {
                    btn.disabled = false;
                    btn.textContent = '記録をPDFで保存';
                    document.body.style.cursor = 'default';
                }
            });
        });
    </script>
</body>
</html>

いいなと思ったら応援しよう!