はじめに

弊社では、業務に生成AIを活用するAI駆動開発を通して商品の開発スピードや品質を向上させています。
本記事では、Microsoft が提供する有償のAIコーディング支援サービス 「GitHub Copilot」によるAI駆動開発のやり方をご紹介します。

AI駆動開発とは

AI駆動開発(AI-driven development / AI-assisted development)とは、ソフトウェア開発のプロセスにAIを積極的に組み込み、効率や品質を高める開発アプローチのことを指します。
主に以下のような工程で効果を発揮します。

  • 要件定義
  • プログラムの実装
  • 改善提案(issue作成)
  • レビュー、テスト(PR作成)

生成AIを組み込むことで、開発者の作業を補助・自動化することができるからです。
各工程でどのようにGitHub Copilotを活用するのかについては、デモプログラムを作りながら紹介します。

実行環境

GitHub Copilotは、同社が提供するGitHub、VS Codeと非常に相性が良いです。
よって、今回はこちらのツールを用いて解説します。

  • VS Code: Microsoft が開発・提供しているソースコードエディタ
  • GitHub:Microsoft 傘下が開発・提供しているソフトウェア開発プラットフォーム

要件定義

今回は、家計簿WEBアプリのデモプログラムを作ってみます。
VS Codeには、下図の右側のようなGitHub Copilotとやり取りを行う専用のチャット欄があります。
こちらの下部でモードをAgentにして、早速GitHub Copilotにこのプログラムの要件を考えてもらいます。

人間が作りたいもの(要求)を与え、要件に落とし込んでもらいましょう。

GitHub Copilotへの指示

シンプルな家計簿WEBアプリを作りたいので、こんな要求を与えてみます。
「index.htmlだけで動作する家計簿を作りたい。必要な要件を提示してください。」
このような回答が返ってきました。

あなたの「index.htmlだけで動く家計簿」の要件を一枚にまとめます。まずは制約を押さえたうえで、機能/非機能/データ/受け入れ基準をチェックリスト化します。

要件チェックリスト
前提・制約
  • [必須] 単一ファイル構成: index.htmlの1ファイルのみ(CSS/JSは埋め込み、外部CDN/画像/フォント不使用)
  • [必須] 完全オフライン動作: ネットワークアクセスなし、で直接開いても機能する
  • [必須] 永続化: IndexedDB(推奨)+ローカルストレージ(フォールバック)
  • [必須] 対応ブラウザ: 最新のChrome/Edge/Safari(macOS)、iOS/iPadOS Safariは最小限で表示/入力が可能
  • [推奨] ファイルサイズ目標: 300KB未満(初期ロード高速化)
  • [制約] Service Worker/PWAは不可(追加ファイルが必要になるため)
機能要件(MVP → 拡張)
  • 取引管理
    • [必須] 収支の登録(収入/支出、日付、金額、カテゴリ、メモ、支払方法、タグ)
    • [必須] 編集/削除(取り消し確認ダイアログ)
    • [必須] 一覧表示(ソート:日付/金額、ページングまたはインフィニティ)
    • [推奨] 検索/フィルタ(期間、カテゴリ、金額範囲、キーワード、収/支)
    • [任意] 複製登録(前回の値を引き継ぎ)
  • 集計/可視化
    • [必須] 月次サマリ(収入合計/支出合計/差額)
    • [推奨] カテゴリ別内訳(円グラフ/SVG)
    • [推奨] 月次推移(棒/折れ線/SVG)
  • カテゴリ/設定
    • [必須] カテゴリ管理(追加/改名/並び替え/色)
    • [推奨] 支払方法/タグの候補管理
    • [推奨] 通貨/小数表示(円:小数なし、区切り/単位設定)
  • 予算/目標
    • [推奨] 月次カテゴリ予算の設定と到達率表示
    • [任意] しきい値超過アラート(色/アイコン)
  • 繰り返し/テンプレート
    • [任意] 繰り返し取引(毎月/毎週/特定日)をテンプレ化→当月に展開
  • データ入出力/バックアップ
    • [必須] エクスポート: JSON(全データ)・CSV(取引一覧)をダウンロード
    • [必須] インポート: JSON/CSV(重複検知/マージ/上書きオプション)
    • [推奨] 自動バックアップ(操作時にローテーション、最大N世代)
  • 便利機能
    • [推奨] キーボード操作(Enterで登録、⌘Kで検索など)
    • [推奨] 印刷用レイアウト(当月レポート/家計簿提出用)
非機能要件
  • パフォーマンス
    • [必須] 5,000件の取引で実用的操作(フィルタ/ソート<300ms、初回描画<1s)
    • [推奨] 遅延レンダリング/仮想リスト採用
  • アクセシビリティ
    • [必須] キーボードフォーカス/コントラスト/ラベル(WCAG AA相当)
    • [推奨] スクリーンリーダー対応ARIA
  • 安全/プライバシー
    • [必須] データはブラウザ内のみ保存(外部送信なし)
    • [推奨] 簡易ロック(パスコードで復号、Web Crypto利用、忘失時は復旧不可の注意表示)
  • 信頼性
    • [必須] バリデーション(必須項目/日付/金額、エラーメッセージ)
    • [推奨] 競合対策(多タブ編集時の最新化検知)
  • 国際化
    • [必須] 日本語UI、日付はJST、和暦不要
    • [推奨] フォーマット抽象化(将来の多言語/通貨切替に備える)
データモデル(ローカルのみ)
  • Transaction
    • id, type(収入|支出), date(YYYY-MM-DD), amount(number/円), categoryId, payMethod(optional), tags[string[]], memo, createdAt, updatedAt
  • Category
    • id, name, color, order
  • Budget
    • id, month(YYYY-MM), categoryId, amount
  • Settings
    • currency, locale, defaultPayMethod, recentCategories[], theme(light/dark), lock(encrypted:bool, salt, iv)
  • RecurringTemplate(任意)
    • id, rule(type/interval/dayOfMonth), baseTransactionFields
画面/UI構成
  • ヘッダー: 月選択、検索バー、合計(収/支/差額)
  • 登録フォーム: タブ(収入/支出)+日付/金額/カテゴリ/メモ/タグ/支払方法、登録ボタン
  • 一覧: テーブル/カード、編集/削除、合計行、無限スクロール
  • サイド/モーダル: カテゴリ管理、予算設定、インポート/エクスポート、設定
  • 可視化: カテゴリ内訳(円)、月次推移(棒/折れ線)— ライブラリ不使用でSVG生成
  • フッター: バックアップ/復元、バージョン表示
受け入れ基準(例)
  • 新規追加→即一覧反映、再読み込み後も保持
  • 指定月の収/支/差額が正しく合算される(小数なし、丸め誤差なし)
  • CSVエクスポート→空白/カンマ/改行を含むメモも破損なしで復元可能
  • 5,000件投入後でも検索<300ms、月次切替<500ms
  • html単体をダブルクリックで開き、全MVP機能が利用可能
既知の制限/リスク
  • 運用はブラウザ差異あり(IndexedDB/ダウンロード動作の差)— Safariでの挙動検証が必要
  • パスコード紛失時の復号不可(ユーザー周知必須)
  • 単一ファイルのためコード肥大化に注意(圧縮/コメント最小化)
次のステップ(提案)
  • 上記MVPで実装開始(取引CRUD/月次サマリ/カテゴリ/入出力/保存)→ 単一htmlを作成
  • その後、予算/チャート/キーボード/印刷を段階追加

実装もこのまま着手できます。MVPに含めたい/外したい項目があれば指示ください。

非常にフワッとした要求でしたが、様々な要件を提案してくれましたね。
今回はせっかくなので、手直しせずこの要件から実装をしてもらいます。

プログラムの実装

先程の要件を基に、GitHub Copilotに「このまま実装をしてください」と指示をしました。
index.htmlファイルが1つだけ作成されています。

このindex.htmlを実際にブラウザで開いてみましょう。

それっぽいUIができていますね。実際にデータを登録した画面が下図です。

特にバグもなく、収入と支出が管理できそうです。

改善提案(issue作成)

作成した家計簿WEBアプリは修正もなく動いてくれましたが、改善してみましょう。
GitHub Copilotでは、2025/05/19より https://github.com/copilot からissueを作成することができるようになりました。
今回はこの機能によって、もっと見やすくなるような改善案を提示してもらいます。
次の指示を与えると、早速issueのドラフトを提示してくれました。

issueの最終確認は人間の仕事です。
おおむね問題がなさそうなので、このまま作成してみましょう。

無事にissueが完成しました。
GitHub Copilotをissueにアサインすると、GitHub Copilot自身に実装してもらうことができます。
図の下部のように、目玉のマークがついてGitHub Copilotがせっせと実装作業を始めてくれます。

レビュー、テスト(PR作成)

実装中のissueはpull requestsを作成して対応してくれます。

処理の間は、PRはdraftとなり、右下のDevelopmentのIn progressからその様子を覗き見ることができます。

In progressでどのような作業を経てこのPRを提出したかを確認できます。
今回の場合、最初にWEBアプリの自動テスト用フレームワークであるplaywright MCPサーバをGitHub Copilotが検証やテストに使うために導入していることが確認できます。
また、これらの作業は最終的に12分53秒で終わり、人間の最終レビュー待ちになっています。

この一連の流れで、GitHub Copilotはデバッグやテストを繰り返し、人の手を介さず独力でPRを作成することができています。

最終的に自動でこのようなPRを提出することができました。

追加した機能や、改善点についてわかりやすくまとめてくれています。

指示していないですが、追加機能のスクリーンショットも用意してくれました。
日本語が文字化けしている点は置いておき、わりやすく変化を紹介してくれていますね。

最終的なレビューは人間が行います。
この内容で問題がなければ、承認しマージします。

改善後の家計簿WEBアプリ

改善後の家計簿WEBアプリを見てみましょう。
以下が、GitHub Copilotが自動で改善した家計簿WEBアプリです。
描画モードの切り替えやグラフ表示など、適切に機能改善がされています。

実際のソースコードはこちらです。ブラウザで開くと動作します(chrome推奨)。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>家計簿(単一ファイル)</title>
    <meta name="description" content="index.htmlだけで動く家計簿" />
    <style>
      :root{
        --bg:#0f172a;/* slate-900 */
        --panel:#111827;/* gray-900 */
        --panel-2:#0b1220;/* darker */
        --text:#e5e7eb;/* gray-200 */
        --muted:#9ca3af;/* gray-400 */
        --accent:#22c55e;/* green-500 */
        --danger:#ef4444;/* red-500 */
        --warn:#f59e0b;/* amber-500 */
        --border:#1f2937;/* gray-800 */
        --shadow:0 8px 20px rgba(0,0,0,.35);
      }
      html,body{height:100%;}
      body{margin:0;background:linear-gradient(180deg,#0b1220 0%,#0f172a 100%);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,Apple Color Emoji,Segoe UI Emoji;}
      .container{max-width:1100px;margin:0 auto;padding:16px 16px 56px;}
      header{position:sticky;top:0;background:linear-gradient(180deg,var(--panel) 0%, rgba(17,24,39,.9) 100%);backdrop-filter:blur(6px);z-index:5;border-bottom:1px solid var(--border);} 
      header .wrap{max-width:1100px;margin:0 auto;padding:12px 16px;display:flex;gap:12px;align-items:center;}
      h1{font-size:18px;margin:0;font-weight:700;letter-spacing:.5px;}
      .spacer{flex:1}
      .toolbar{display:flex;gap:8px;flex-wrap:wrap}
      .btn{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:8px;cursor:pointer;transition:.15s;box-shadow:var(--shadow);}
      .btn:hover{background:#121a2c}
      .btn.accent{background:linear-gradient(135deg,#16a34a,#22c55e);border:none;color:#06280f;font-weight:700}
      .btn.warn{background:linear-gradient(135deg,#b45309,#f59e0b);border:none;color:#2b1700;font-weight:700}
      .btn.ghost{background:transparent;border:1px dashed var(--border)}
      select,input,textarea{background:#0b1220;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 10px;outline:none}
      input[type="date"]{padding:7px 10px}
      .card{background:linear-gradient(180deg,#0b1220,#101a2f);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow)}
      .grid{display:grid;gap:12px}
      .grid.col-3{grid-template-columns:repeat(3,1fr)}
      @media (max-width:900px){.grid.col-3{grid-template-columns:1fr}}
      .summary{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:12px}
      @media (max-width:900px){.summary{grid-template-columns:1fr}}
      .summary .item{padding:14px}
      .summary .label{color:var(--muted);font-size:12px}
      .summary .value{font-size:22px;font-weight:800;margin-top:8px}
      .summary .income{color:#34d399}
      .summary .expense{color:#f87171}
      .summary .balance{color:#93c5fd}

      .section{margin-top:16px}
      .section h2{font-size:14px;color:var(--muted);margin:0 0 8px 4px;letter-spacing:.3px}
      .row{display:flex;gap:8px;flex-wrap:wrap}
      .row > *{flex:1 1 160px}
      .table{width:100%;border-collapse:separate;border-spacing:0 8px}
      .table thead th{font-size:12px;color:var(--muted);text-align:left;padding:0 12px;font-weight:600}
      .table tbody tr{background:linear-gradient(180deg,#0b1220,#0e1a2c);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:all .15s}
      .table tbody tr:hover{transform:translateY(-1px);box-shadow:var(--shadow)}
      .table td{padding:12px;border-bottom:1px solid transparent;font-size:14px;line-height:1.4}
      .table .category-cell{display:flex;align-items:center;gap:8px}
      .color-dot{width:14px;height:14px;border-radius:50%;display:inline-block;border:2px solid var(--border);flex-shrink:0}
      .category-name{font-weight:500}
      .type-badge{display:inline-block;padding:4px 8px;border-radius:999px;font-size:12px;font-weight:700}
      .type-income{background:rgba(34,197,94,.15);color:#86efac;border:1px solid rgba(34,197,94,.25)}
      .type-expense{background:rgba(239,68,68,.15);color:#fecaca;border:1px solid rgba(239,68,68,.25)}
      .pill{display:inline-block;background:#0b1220;border:1px solid var(--border);padding:4px 8px;border-radius:999px;font-size:12px;color:var(--muted)}
      .danger{color:#fecaca}
      .muted{color:var(--muted)}
      .right{text-align:right}
      .center{text-align:center}
      .hidden{display:none !important}
      .modal{position:fixed;inset:0;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:10}
      .modal .dialog{width:min(720px,94vw);max-height:88vh;overflow:auto}
      .chips{display:flex;gap:6px;flex-wrap:wrap}
      .chip{padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:#0b1220;font-size:12px}
      .footer{margin-top:20px;color:var(--muted);font-size:12px;text-align:center}
      .note{font-size:12px;color:var(--muted)}
      
      /* Theme toggle */
      .theme-toggle{background:var(--panel);border:1px solid var(--border);padding:4px;border-radius:20px;display:flex;position:relative}
      .theme-toggle button{background:transparent;border:none;color:var(--muted);padding:6px 12px;border-radius:16px;cursor:pointer;transition:all .2s;font-size:12px}
      .theme-toggle button.active{background:var(--accent);color:var(--bg);font-weight:600}
      
      /* Light theme */
      [data-theme="light"]{
        --bg:#f8fafc;
        --panel:#ffffff;
        --panel-2:#f1f5f9;
        --text:#1e293b;
        --muted:#64748b;
        --accent:#22c55e;
        --danger:#ef4444;
        --warn:#f59e0b;
        --border:#e2e8f0;
        --shadow:0 8px 20px rgba(15,23,42,.08);
      }
      [data-theme="light"] body{background:linear-gradient(180deg,#f8fafc 0%,#f1f5f9 100%)}
      [data-theme="light"] .card{background:linear-gradient(180deg,#ffffff,#f8fafc);border:1px solid var(--border)}
      [data-theme="light"] header{background:linear-gradient(180deg,var(--panel) 0%, rgba(255,255,255,.9) 100%)}
      [data-theme="light"] .table tbody tr{background:linear-gradient(180deg,#ffffff,#f8fafc)}
      [data-theme="light"] select, [data-theme="light"] input, [data-theme="light"] textarea{background:#ffffff}
      [data-theme="light"] .btn{background:#f1f5f9}
      [data-theme="light"] .btn:hover{background:#e2e8f0}
      [data-theme="light"] .pill{background:#f1f5f9}
      [data-theme="light"] .chip{background:#f1f5f9}
      
      /* Chart container */
      .chart-section{margin-top:16px}
      .chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
      @media (max-width:900px){.chart-grid{grid-template-columns:1fr}}
      .chart-container{position:relative;height:280px;display:flex;align-items:center;justify-content:center}
      .chart-svg{width:100%;height:100%}
      .chart-legend{display:flex;gap:16px;justify-content:center;margin-top:8px;flex-wrap:wrap}
      .legend-item{display:flex;align-items:center;gap:4px;font-size:12px}
      .legend-color{width:12px;height:12px;border-radius:2px}
      .no-data{color:var(--muted);font-style:italic}
      
      /* Simple bar chart styles */
      .bar-chart{display:flex;align-items:end;height:200px;gap:8px;padding:20px 0}
      .bar{background:var(--accent);border-radius:4px 4px 0 0;position:relative;min-height:4px;transition:all .2s}
      .bar:hover{opacity:.8;transform:translateY(-2px)}
      .bar-label{position:absolute;bottom:-24px;left:50%;transform:translateX(-50%);font-size:10px;color:var(--muted);text-align:center;width:60px}
      .bar-value{position:absolute;top:-20px;left:50%;transform:translateX(-50%);font-size:10px;color:var(--text);white-space:nowrap}
    </style>
  </head>
  <body>
    <header>
      <div class="wrap">
        <h1>家計簿</h1>
        <div class="spacer"></div>
        <div class="theme-toggle">
          <button id="btnThemeDark" class="active">🌙</button>
          <button id="btnThemeLight">☀️</button>
        </div>
        <label class="muted" for="monthPicker">月:</label>
        <input id="monthPicker" type="month" />
        <div class="toolbar">
          <button id="btnNew" class="btn accent">+ 新規</button>
          <button id="btnCategories" class="btn">カテゴリ</button>
          <button id="btnExportJSON" class="btn">JSON書き出し</button>
          <button id="btnExportCSV" class="btn">CSV書き出し</button>
          <button id="btnImport" class="btn warn">インポート</button>
          <input id="fileInput" type="file" accept=".json,.csv" class="hidden" />
        </div>
      </div>
    </header>
    <div class="container">
      <section class="summary">
        <div class="card summary item">
          <div class="label">収入</div>
          <div id="sumIncome" class="value income">¥0</div>
        </div>
        <div class="card summary item">
          <div class="label">支出</div>
          <div id="sumExpense" class="value expense">¥0</div>
        </div>
        <div class="card summary item">
          <div class="label">差額</div>
          <div id="sumBalance" class="value balance">¥0</div>
        </div>
      </section>

      <section class="chart-section">
        <h2>グラフ</h2>
        <div class="chart-grid">
          <div class="card" style="padding:14px">
            <h3 style="margin:0 0 12px 0;font-size:14px;color:var(--muted)">収支バランス</h3>
            <div class="chart-container">
              <svg id="balanceChart" class="chart-svg" viewBox="0 0 200 200"></svg>
            </div>
            <div id="balanceLegend" class="chart-legend"></div>
          </div>
          <div class="card" style="padding:14px">
            <h3 style="margin:0 0 12px 0;font-size:14px;color:var(--muted)">カテゴリ別支出</h3>
            <div class="chart-container">
              <div id="categoryChart" class="bar-chart"></div>
            </div>
          </div>
        </div>
      </section>

      <section class="section">
        <h2>登録 / 編集</h2>
        <div id="formCard" class="card" style="padding:14px">
          <form id="txForm" autocomplete="off">
            <input type="hidden" id="txId" />
            <div class="row">
              <div>
                <label class="muted">種別</label><br />
                <select id="txType">
                  <option value="expense">支出</option>
                  <option value="income">収入</option>
                </select>
              </div>
              <div>
                <label class="muted">日付</label><br />
                <input id="txDate" type="date" required />
              </div>
              <div>
                <label class="muted">金額</label><br />
                <input id="txAmount" type="number" inputmode="numeric" step="1" min="0" placeholder="0" required />
              </div>
              <div>
                <label class="muted">カテゴリ</label><br />
                <select id="txCategory" required></select>
              </div>
            </div>
            <div class="row" style="margin-top:8px">
              <div>
                <label class="muted">支払方法</label><br />
                <input id="txPay" type="text" placeholder="現金/カード など" />
              </div>
              <div>
                <label class="muted">タグ</label><br />
                <input id="txTags" type="text" placeholder="カンマ区切り" />
              </div>
              <div style="flex:2 1 auto">
                <label class="muted">メモ</label><br />
                <input id="txMemo" type="text" placeholder="メモ" />
              </div>
            </div>
            <div class="row" style="margin-top:12px">
              <div class="spacer"></div>
              <button type="button" id="btnCancelEdit" class="btn ghost hidden">キャンセル</button>
              <button id="btnSubmit" class="btn accent">登録</button>
            </div>
          </form>
        </div>
      </section>

      <section class="section">
        <h2>検索 / 一覧</h2>
        <div class="card" style="padding:12px;margin-bottom:8px">
          <div class="row">
            <div>
              <label class="muted">種別</label><br />
              <select id="fType">
                <option value="all">すべて</option>
                <option value="expense">支出</option>
                <option value="income">収入</option>
              </select>
            </div>
            <div>
              <label class="muted">カテゴリ</label><br />
              <select id="fCategory">
                <option value="all">すべて</option>
              </select>
            </div>
            <div style="flex:2 1 auto">
              <label class="muted">キーワード</label><br />
              <input id="fKeyword" type="text" placeholder="メモ/タグ/支払方法を検索" />
            </div>
          </div>
          <div class="row" style="margin-top:8px">
            <div>
              <label class="muted">開始日</label><br />
              <input id="fDateFrom" type="date" />
            </div>
            <div>
              <label class="muted">終了日</label><br />
              <input id="fDateTo" type="date" />
            </div>
            <div>
              <label class="muted">金額(最小)</label><br />
              <input id="fAmountMin" type="number" inputmode="numeric" step="1" min="0" placeholder="0" />
            </div>
            <div>
              <label class="muted">金額(最大)</label><br />
              <input id="fAmountMax" type="number" inputmode="numeric" step="1" min="0" placeholder="上限なし" />
            </div>
          </div>
        </div>
        <div class="card" style="padding:4px 8px">
          <table class="table">
            <thead>
              <tr>
                <th style="width:110px">日付</th>
                <th style="width:90px">種別</th>
                <th>カテゴリ</th>
                <th class="right" style="width:140px">金額</th>
                <th>支払</th>
                <th>タグ</th>
                <th>メモ</th>
                <th class="center" style="width:120px">操作</th>
              </tr>
            </thead>
            <tbody id="txTbody"></tbody>
          </table>
          <div id="emptyHint" class="muted" style="padding:14px">この月の記録はありません。</div>
        </div>
      </section>

      <div class="footer">v0.1 – データはこの端末のブラウザ内にのみ保存されます</div>
    </div>

    <!-- カテゴリ管理モーダル -->
    <div id="catModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="catTitle">
      <div class="dialog card" style="padding:14px">
        <div style="display:flex;align-items:center;gap:8px">
          <h3 id="catTitle" style="margin:0;font-size:16px">カテゴリ管理</h3>
          <div class="spacer"></div>
          <button id="btnCloseCat" class="btn ghost">閉じる</button>
        </div>
        <div style="margin-top:10px" class="note">色は表示の目安です。名称変更や並べ替えができます。</div>
        <div id="catList" style="margin-top:10px;display:flex;flex-direction:column;gap:8px"></div>
        <div class="row" style="margin-top:12px">
          <input id="newCatName" type="text" placeholder="新しいカテゴリ名" />
          <input id="newCatColor" type="color" value="#22c55e" />
          <button id="btnAddCat" class="btn">追加</button>
        </div>
      </div>
    </div>

    <script type="module">
      // ===== ユーティリティ =====
      const $ = (sel, root=document) => root.querySelector(sel);
      const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
      const today = () => new Date().toISOString().slice(0,10);
      const ymOf = (dateStr) => dateStr.slice(0,7);
      const fmtJPY = new Intl.NumberFormat('ja-JP',{style:'currency',currency:'JPY',maximumFractionDigits:0});
      const uuid = () => (crypto?.randomUUID?.() || ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,c=>(c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16)));
      const sleep = (ms)=> new Promise(r=>setTimeout(r,ms));

      // ===== ストレージ層(IndexedDB優先、失敗時localStorage) =====
      class StorageAdapter {
        constructor(){
          this.kind = 'idb';
          this.db = null;
        }
        async init(){
          if(!('indexedDB' in window)) return this._initLocal();
          try{
            this.db = await new Promise((resolve,reject)=>{
              const req = indexedDB.open('kakeibo.singlefile',2);
              req.onupgradeneeded = (e)=>{
                const db = req.result;
                if(!db.objectStoreNames.contains('transactions')){
                  db.createObjectStore('transactions',{keyPath:'id'});
                }
                if(!db.objectStoreNames.contains('categories')){
                  db.createObjectStore('categories',{keyPath:'id'});
                }
                if(!db.objectStoreNames.contains('meta')){
                  db.createObjectStore('meta',{keyPath:'key'});
                }
              };
              req.onsuccess = ()=> resolve(req.result);
              req.onerror = ()=> reject(req.error);
            });
            this.kind = 'idb';
          }catch(err){
            console.warn('IndexedDB unavailable, fallback to localStorage', err);
            return this._initLocal();
          }
        }
        _initLocal(){
          this.kind='local';
          if(!localStorage.getItem('kkb.transactions')) localStorage.setItem('kkb.transactions','[]');
          if(!localStorage.getItem('kkb.categories')) localStorage.setItem('kkb.categories','[]');
          return Promise.resolve();
        }
        _tx(store,mode='readonly'){
          return this.db.transaction(store, mode).objectStore(store);
        }
        // ---- Transactions ----
        async listTransactions(){
          if(this.kind==='local') return JSON.parse(localStorage.getItem('kkb.transactions')||'[]');
          return new Promise((resolve,reject)=>{
            const items=[]; const req = this._tx('transactions').openCursor();
            req.onsuccess = ()=>{ const cur = req.result; if(cur){ items.push(cur.value); cur.continue(); } else resolve(items); };
            req.onerror = ()=> reject(req.error);
          });
        }
        async putTransaction(tx){
          if(this.kind==='local'){
            const arr = await this.listTransactions();
            const idx = arr.findIndex(x=>x.id===tx.id);
            if(idx>=0) arr[idx]=tx; else arr.push(tx);
            localStorage.setItem('kkb.transactions',JSON.stringify(arr));
            return;
          }
          return new Promise((resolve,reject)=>{
            const req = this._tx('transactions','readwrite').put(tx);
            req.onsuccess=()=>resolve(); req.onerror=()=>reject(req.error);
          });
        }
        async deleteTransaction(id){
          if(this.kind==='local'){
            const arr = await this.listTransactions();
            localStorage.setItem('kkb.transactions', JSON.stringify(arr.filter(x=>x.id!==id)));
            return;
          }
          return new Promise((resolve,reject)=>{
            const req = this._tx('transactions','readwrite').delete(id);
            req.onsuccess=()=>resolve(); req.onerror=()=>reject(req.error);
          });
        }
        // ---- Categories ----
        async listCategories(){
          if(this.kind==='local') return JSON.parse(localStorage.getItem('kkb.categories')||'[]');
          return new Promise((resolve,reject)=>{
            const items=[]; const req = this._tx('categories').openCursor();
            req.onsuccess = ()=>{ const cur = req.result; if(cur){ items.push(cur.value); cur.continue(); } else resolve(items); };
            req.onerror = ()=> reject(req.error);
          });
        }
        async putCategory(cat){
          if(this.kind==='local'){
            const arr = await this.listCategories();
            const idx = arr.findIndex(x=>x.id===cat.id);
            if(idx>=0) arr[idx]=cat; else arr.push(cat);
            localStorage.setItem('kkb.categories',JSON.stringify(arr));
            return;
          }
          return new Promise((resolve,reject)=>{
            const req = this._tx('categories','readwrite').put(cat);
            req.onsuccess=()=>resolve(); req.onerror=()=>reject(req.error);
          });
        }
        async deleteCategory(id){
          if(this.kind==='local'){
            const arr = await this.listCategories();
            localStorage.setItem('kkb.categories', JSON.stringify(arr.filter(x=>x.id!==id)));
            return;
          }
          return new Promise((resolve,reject)=>{
            const req = this._tx('categories','readwrite').delete(id);
            req.onsuccess=()=>resolve(); req.onerror=()=>reject(req.error);
          });
        }
      }

      // ===== アプリ状態 =====
      const DB = new StorageAdapter();
      let transactions = [];
      let categories = [];
      let currentMonth = new Date().toISOString().slice(0,7);
      let currentTheme = localStorage.getItem('kkb.theme') || 'dark';

      // ===== テーマ管理 =====
      function initTheme(){
        document.documentElement.setAttribute('data-theme', currentTheme);
        $('#btnThemeDark').classList.toggle('active', currentTheme === 'dark');
        $('#btnThemeLight').classList.toggle('active', currentTheme === 'light');
      }
      function setTheme(theme){
        currentTheme = theme;
        localStorage.setItem('kkb.theme', theme);
        initTheme();
        // チャートの再描画(テーマに応じた色に更新)
        setTimeout(renderCharts, 100);
      }

      const defaultCategories = () => {
        const names = ['食費','日用品','交通','住居','水道光熱','通信','娯楽','教育','医療','その他','給与','臨時収入'];
        return names.map((name,i)=>({id:uuid(), name, color:hsl(i*30 % 360,60,50), order:i}));
      };
      function hsl(h,s,l){ return `hsl(${h} ${s}% ${l}%)`; }

      // ===== 初期化 =====
      async function init(){
        initTheme();
        await DB.init();
        categories = await DB.listCategories();
        if(categories.length===0){
          for(const c of defaultCategories()) await DB.putCategory(c);
          categories = await DB.listCategories();
        }
        transactions = await DB.listTransactions();
        // UI初期値
        $('#monthPicker').value = currentMonth;
        $('#txDate').value = today();
        renderCategorySelects();
        renderFilters();
        renderAll();
        wireEvents();
      }

      // ===== レンダリング =====
      function renderCategorySelects(){
        const sel = $('#txCategory'); sel.innerHTML='';
        for(const c of [...categories].sort((a,b)=>a.order-b.order)){
          const opt = document.createElement('option');
          opt.value=c.id; opt.textContent=c.name; sel.appendChild(opt);
        }
        const f = $('#fCategory'); f.innerHTML='';
        const all = document.createElement('option'); all.value='all'; all.textContent='すべて'; f.appendChild(all);
        for(const c of [...categories].sort((a,b)=>a.order-b.order)){
          const opt = document.createElement('option'); opt.value=c.id; opt.textContent=c.name; f.appendChild(opt);
        }
      }
      function renderFilters(){
        $('#fType').value='all';
        $('#fCategory').value='all';
        $('#fKeyword').value='';
        $('#fDateFrom').value='';
        $('#fDateTo').value='';
        $('#fAmountMin').value='';
        $('#fAmountMax').value='';
      }
      function filteredTx(){
        const ym = $('#monthPicker').value;
        const t = $('#fType').value;
        const cat = $('#fCategory').value;
        const kw = $('#fKeyword').value.trim();
        const dateFrom = $('#fDateFrom').value;
        const dateTo = $('#fDateTo').value;
        const amountMin = $('#fAmountMin').value;
        const amountMax = $('#fAmountMax').value;
        
        return transactions.filter(x=>{
          if(ymOf(x.date)!==ym) return false;
          if(t!=='all' && x.type!==t) return false;
          if(cat!=='all' && x.categoryId!==cat) return false;
          if(kw){
            const hay = `${x.memo||''} ${(x.tags||[]).join(' ')} ${(x.payMethod||'')}`.toLowerCase();
            if(!hay.includes(kw.toLowerCase())) return false;
          }
          if(dateFrom && x.date < dateFrom) return false;
          if(dateTo && x.date > dateTo) return false;
          if(amountMin && Number(x.amount||0) < Number(amountMin)) return false;
          if(amountMax && Number(x.amount||0) > Number(amountMax)) return false;
          return true;
        }).sort((a,b)=> (a.date===b.date? (b.createdAt-a.createdAt) : (a.date<b.date?1:-1)) );
      }
      function renderSummary(){
        const list = filteredTx();
        const inc = list.filter(x=>x.type==='income').reduce((s,x)=>s+Number(x.amount||0),0);
        const exp = list.filter(x=>x.type==='expense').reduce((s,x)=>s+Number(x.amount||0),0);
        $('#sumIncome').textContent = fmtJPY.format(inc);
        $('#sumExpense').textContent = fmtJPY.format(exp);
        $('#sumBalance').textContent = fmtJPY.format(inc-exp);
      }
      function renderTable(){
        const tbody = $('#txTbody');
        tbody.innerHTML='';
        const list = filteredTx();
        $('#emptyHint').classList.toggle('hidden', list.length>0);
        const frag = document.createDocumentFragment();
        for(const x of list){
          const tr = document.createElement('tr');
          const cat = categories.find(c=>c.id===x.categoryId);
          tr.innerHTML = `
            <td>${x.date}</td>
            <td><span class="type-badge ${x.type==='income'?'type-income':'type-expense'}">${x.type==='income'?'収入':'支出'}</span></td>
            <td><div class="category-cell"><span class="color-dot" style="background:${cat?.color||'#444'}"></span><span class="category-name">${cat?.name||'-'}</span></div></td>
            <td class="right">${fmtJPY.format(x.amount||0)}</td>
            <td>${x.payMethod?`<span class="pill">${esc(x.payMethod)}</span>`:''}</td>
            <td>${(x.tags||[]).map(t=>`<span class="pill">${esc(t)}</span>`).join(' ')}</td>
            <td class="muted">${esc(x.memo||'')}</td>
            <td class="center">
              <button class="btn" data-act="edit" data-id="${x.id}">編集</button>
              <button class="btn" data-act="del" data-id="${x.id}">削除</button>
            </td>`;
          frag.appendChild(tr);
        }
        tbody.appendChild(frag);
      }
      function renderAll(){
        renderSummary();
        renderTable();
        renderCharts();
      }

      // ===== チャート描画 =====
      function renderCharts(){
        renderBalanceChart();
        renderCategoryChart();
      }
      
      function renderBalanceChart(){
        const svg = document.getElementById('balanceChart');
        const legend = document.getElementById('balanceLegend');
        if(!svg || !legend) return;
        
        const list = filteredTx();
        const inc = list.filter(x=>x.type==='income').reduce((s,x)=>s+Number(x.amount||0),0);
        const exp = list.filter(x=>x.type==='expense').reduce((s,x)=>s+Number(x.amount||0),0);
        const total = inc + exp;
        
        if(total === 0) {
          svg.innerHTML = '<text x="100" y="100" text-anchor="middle" fill="var(--muted)" font-size="14">データがありません</text>';
          legend.innerHTML = '';
          return;
        }
        
        const incAngle = (inc / total) * 360;
        const expAngle = (exp / total) * 360;
        
        const incPath = createPieSlice(100, 100, 80, 0, incAngle, '#34d399');
        const expPath = createPieSlice(100, 100, 80, incAngle, incAngle + expAngle, '#f87171');
        
        svg.innerHTML = incPath + expPath;
        
        legend.innerHTML = `
          <div class="legend-item">
            <div class="legend-color" style="background:#34d399"></div>
            <span>収入: ${fmtJPY.format(inc)}</span>
          </div>
          <div class="legend-item">
            <div class="legend-color" style="background:#f87171"></div>
            <span>支出: ${fmtJPY.format(exp)}</span>
          </div>
        `;
      }
      
      function createPieSlice(centerX, centerY, radius, startAngle, endAngle, color) {
        const start = polarToCartesian(centerX, centerY, radius, endAngle);
        const end = polarToCartesian(centerX, centerY, radius, startAngle);
        const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
        const pathData = `M ${centerX} ${centerY} L ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y} Z`;
        return `<path d="${pathData}" fill="${color}" stroke="var(--bg)" stroke-width="2"/>`;
      }
      
      function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
        const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
        return {
          x: centerX + (radius * Math.cos(angleInRadians)),
          y: centerY + (radius * Math.sin(angleInRadians))
        };
      }
      
      function renderCategoryChart(){
        const container = document.getElementById('categoryChart');
        if(!container) return;
        
        const list = filteredTx().filter(x=>x.type==='expense');
        const categoryData = {};
        
        for(const tx of list) {
          const cat = categories.find(c=>c.id===tx.categoryId);
          const catName = cat?.name || 'その他';
          const catColor = cat?.color || '#888';
          if(!categoryData[catName]) {
            categoryData[catName] = { amount: 0, color: catColor };
          }
          categoryData[catName].amount += Number(tx.amount || 0);
        }
        
        const sortedData = Object.entries(categoryData)
          .sort(([,a], [,b]) => b.amount - a.amount)
          .slice(0, 6); // 上位6カテゴリのみ表示
        
        if(sortedData.length === 0) {
          container.innerHTML = '<div class="no-data">データがありません</div>';
          return;
        }
        
        const maxAmount = Math.max(...sortedData.map(([,data]) => data.amount));
        const maxHeight = 160;
        
        container.innerHTML = sortedData.map(([name, data]) => {
          const height = Math.max(4, (data.amount / maxAmount) * maxHeight);
          const width = Math.max(40, (container.offsetWidth - (sortedData.length + 1) * 8) / sortedData.length);
          return `
            <div class="bar" style="height:${height}px;width:${width}px;background:${data.color}">
              <div class="bar-value">${fmtJPY.format(data.amount)}</div>
              <div class="bar-label">${name}</div>
            </div>
          `;
        }).join('');
      }
  const esc = (s)=> String(s).replace(/[&<>"]/g, c=>({"&":"&","<":"<",">":">","\"":"""}[c]));

      // ===== イベント配線 =====
      function wireEvents(){
        $('#monthPicker').addEventListener('change', ()=>{ currentMonth = $('#monthPicker').value; renderAll(); });
        $('#btnNew').addEventListener('click', ()=> resetForm());
        $('#txForm').addEventListener('submit', onSubmitTx);
        $('#btnCancelEdit').addEventListener('click', ()=> resetForm());
        $('#txTbody').addEventListener('click', onTableClick);
        $('#btnCategories').addEventListener('click', openCatModal);
        $('#btnCloseCat').addEventListener('click', closeCatModal);
        $('#btnAddCat').addEventListener('click', addCategoryFromUI);
        $('#btnExportJSON').addEventListener('click', exportJSON);
        $('#btnExportCSV').addEventListener('click', exportCSV);
        $('#btnImport').addEventListener('click', ()=> $('#fileInput').click());
        $('#fileInput').addEventListener('change', onImportFile);
        // theme toggle
        $('#btnThemeDark').addEventListener('click', ()=> setTheme('dark'));
        $('#btnThemeLight').addEventListener('click', ()=> setTheme('light'));
        // filters
        $('#fType').addEventListener('change', renderAll);
        $('#fCategory').addEventListener('change', renderAll);
        $('#fKeyword').addEventListener('input', ()=>{ renderAll(); });
        $('#fDateFrom').addEventListener('change', renderAll);
        $('#fDateTo').addEventListener('change', renderAll);
        $('#fAmountMin').addEventListener('input', ()=>{ renderAll(); });
        $('#fAmountMax').addEventListener('input', ()=>{ renderAll(); });
      }

      // ===== 取引フォーム =====
      function resetForm(){
        $('#txId').value='';
        $('#txType').value='expense';
        $('#txDate').value=today();
        $('#txAmount').value='';
        $('#txCategory').selectedIndex=0;
        $('#txPay').value='';
        $('#txTags').value='';
        $('#txMemo').value='';
        $('#btnSubmit').textContent='登録';
        $('#btnCancelEdit').classList.add('hidden');
        $('#txType').focus();
      }
      async function onSubmitTx(e){
        e.preventDefault();
        const id = $('#txId').value || uuid();
        const t = $('#txType').value;
        const date = $('#txDate').value;
        const amount = Math.max(0, Math.floor(Number($('#txAmount').value||0)));
        const categoryId = $('#txCategory').value;
        const payMethod = $('#txPay').value.trim();
        const tags = $('#txTags').value.split(',').map(s=>s.trim()).filter(Boolean);
        const memo = $('#txMemo').value.trim();
        if(!date){ alert('日付を入力してください'); return; }
        if(!categoryId){ alert('カテゴリを選択してください'); return; }
        if(!(amount>0)){ alert('金額は1円以上で入力してください'); return; }
        const now = Date.now();
        const exists = transactions.find(x=>x.id===id);
        const tx = {id, type:t, date, amount, categoryId, payMethod, tags, memo, createdAt: exists?.createdAt || now, updatedAt: now};
        await DB.putTransaction(tx);
        // メモリ反映
        if(exists){ Object.assign(exists, tx); }
        else { transactions.push(tx); }
        resetForm();
        renderAll();
      }
      function onTableClick(e){
        const btn = e.target.closest('button'); if(!btn) return;
        const id = btn.getAttribute('data-id');
        const act = btn.getAttribute('data-act');
        const tx = transactions.find(x=>x.id===id);
        if(!tx) return;
        if(act==='edit'){
          $('#txId').value = tx.id;
          $('#txType').value = tx.type;
          $('#txDate').value = tx.date;
          $('#txAmount').value = tx.amount;
          $('#txCategory').value = tx.categoryId;
          $('#txPay').value = tx.payMethod||'';
          $('#txTags').value = (tx.tags||[]).join(', ');
          $('#txMemo').value = tx.memo||'';
          $('#btnSubmit').textContent='更新';
          $('#btnCancelEdit').classList.remove('hidden');
          window.scrollTo({top:0,behavior:'smooth'});
        } else if(act==='del'){
          if(confirm('削除してよろしいですか?この操作は取り消せません。')){
            DB.deleteTransaction(id).then(()=>{
              transactions = transactions.filter(x=>x.id!==id);
              renderAll();
            });
          }
        }
      }

      // ===== カテゴリ管理 =====
      function openCatModal(){ renderCatList(); $('#catModal').classList.remove('hidden'); }
      function closeCatModal(){ $('#catModal').classList.add('hidden'); }
      function renderCatList(){
        const wrap = $('#catList'); wrap.innerHTML='';
        for(const c of [...categories].sort((a,b)=>a.order-b.order)){
          const row = document.createElement('div'); row.className='card'; row.style.padding='10px';
          row.innerHTML = `
            <div style="display:flex;gap:8px;align-items:center">
              <span class="color-dot" style="background:${c.color}"></span>
              <input data-id="${c.id}" data-k="name" type="text" value="${esc(c.name)}" style="flex:1 1 auto" />
              <input data-id="${c.id}" data-k="color" type="color" value="${toHex(c.color)}" />
              <button class="btn" data-act="up" data-id="${c.id}">↑</button>
              <button class="btn" data-act="down" data-id="${c.id}">↓</button>
              <button class="btn" data-act="del" data-id="${c.id}">削除</button>
            </div>`;
          wrap.appendChild(row);
        }
        wrap.addEventListener('input', onCatEdit, {once:true, passive:true});
        wrap.addEventListener('click', onCatClick, {once:true});
      }
      function onCatEdit(e){
        const t = e.target; if(!(t instanceof HTMLInputElement)) return;
        const id = t.getAttribute('data-id'); const key = t.getAttribute('data-k');
        const cat = categories.find(c=>c.id===id); if(!cat) return;
        cat[key] = t.value;
        DB.putCategory(cat);
        renderCategorySelects();
        // 再度イベントデリゲート
        setTimeout(()=>{ $('#catList').addEventListener('input', onCatEdit, {once:true, passive:true}); },0);
      }
      function onCatClick(e){
        const b = e.target.closest('button'); if(!b) return;
        const id = b.getAttribute('data-id'); const act = b.getAttribute('data-act');
        const idx = categories.findIndex(c=>c.id===id); if(idx<0) return;
        if(act==='del'){
          const used = transactions.some(x=>x.categoryId===id);
          if(used){ alert('このカテゴリは使用中のため削除できません'); }
          else { DB.deleteCategory(id).then(async ()=>{ categories.splice(idx,1); renumberOrders(); renderCategorySelects(); renderCatList(); renderAll();}); }
        } else if(act==='up' || act==='down'){
          const j = act==='up'? idx-1 : idx+1;
          if(j<0 || j>=categories.length) return;
          const tmp = categories[idx]; categories[idx]=categories[j]; categories[j]=tmp;
          renumberOrders();
          Promise.all(categories.map(c=>DB.putCategory(c))); 
          renderCategorySelects(); renderCatList(); renderAll();
        }
        setTimeout(()=>{ $('#catList').addEventListener('click', onCatClick, {once:true}); },0);
      }
      function renumberOrders(){ categories.forEach((c,i)=> c.order=i); }
      function toHex(col){
        // accepts hsl(...) or hex. For hsl, convert approximately to hex.
        if(/^#/.test(col)) return col;
        const m = /hsl\((\d+)\s+(\d+)%\s+(\d+)%\)/.exec(col);
        if(!m) return '#22c55e';
        const [h,s,l] = [Number(m[1]),Number(m[2])/100,Number(m[3])/100];
        const c = (1-Math.abs(2*l-1))*s; const x = c*(1-Math.abs((h/60)%2-1)); const m0=l-c/2;
        let [r,g,b]=[0,0,0];
        if(h<60){[r,g,b]=[c,x,0]} else if(h<120){[r,g,b]=[x,c,0]} else if(h<180){[r,g,b]=[0,c,x]} else if(h<240){[r,g,b]=[0,x,c]} else if(h<300){[r,g,b]=[x,0,c]} else {[r,g,b]=[c,0,x]}
        const to255 = (v)=> Math.round((v+m0)*255);
        const hx = (n)=> n.toString(16).padStart(2,'0');
        return `#${hx(to255(r))}${hx(to255(g))}${hx(to255(b))}`;
      }
      async function addCategoryFromUI(){
        const name = $('#newCatName').value.trim(); if(!name) return;
        const color = $('#newCatColor').value || '#22c55e';
        const cat = {id:uuid(), name, color, order: categories.length};
        await DB.putCategory(cat); categories.push(cat);
        $('#newCatName').value='';
        renderCategorySelects(); renderCatList(); renderAll();
      }

      // ===== エクスポート / インポート =====
      function download(filename, text){
        const blob = new Blob([text], {type: 'text/plain;charset=utf-8'});
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob); a.download = filename; a.click();
        setTimeout(()=> URL.revokeObjectURL(a.href), 5000);
      }
      function exportJSON(){
        const dump = {version:'0.1', exportedAt: new Date().toISOString(), categories, transactions};
        download(`kakeibo_${$('#monthPicker').value}.json`, JSON.stringify(dump, null, 2));
      }
      function exportCSV(){
        const cols = ['id','type','date','amount','category','payMethod','tags','memo'];
        const list = filteredTx();
        const lines = [cols.join(',')];
        for(const x of list){
          const cat = categories.find(c=>c.id===x.categoryId)?.name||'';
          const row = [x.id,x.type,x.date,String(x.amount),cat,(x.payMethod||''),(x.tags||[]).join(' '),(x.memo||'')].map(csvCell).join(',');
          lines.push(row);
        }
        download(`kakeibo_${$('#monthPicker').value}.csv`, lines.join('\n'));
      }
      function csvCell(s){
        const t = String(s??'');
        if(/[",\n]/.test(t)) return '"'+t.replace(/"/g,'""')+'"';
        return t;
      }
      async function onImportFile(e){
        const file = e.target.files?.[0]; if(!file) return;
        const text = await file.text();
        if(file.name.endsWith('.json') || text.trim().startsWith('{')){
          await importJSON(text);
        } else {
          await importCSV(text);
        }
        e.target.value='';
      }
      async function importJSON(text){
        let data; try{ data = JSON.parse(text); }catch{ alert('JSONの解析に失敗しました'); return; }
        const merge = confirm('JSONを読み込みます。既存データにマージしますか?\nキャンセルで中止、OKでマージ、OKを押した後に続けて進みます。');
        if(!merge) return;
        const catByName = new Map(categories.map(c=>[c.name,c]));
        // カテゴリ
        if(Array.isArray(data.categories)){
          for(const c of data.categories){
            let existing = categories.find(x=>x.id===c.id) || catByName.get(c.name);
            if(existing){ existing.name=c.name; existing.color=c.color||existing.color; }
            else { existing = {id:uuid(), name:c.name, color:c.color||'#22c55e', order: categories.length}; categories.push(existing); }
            await DB.putCategory(existing);
          }
          renumberOrders();
        }
        // 取引
        if(Array.isArray(data.transactions)){
          const nameToId = new Map(categories.map(c=>[c.name,c.id]));
          let added=0, updated=0;
          for(const t of data.transactions){
            const id = t.id || uuid();
            const catId = t.categoryId || nameToId.get(t.category) || categories[0]?.id;
            const now = Date.now();
            const tx = {id, type:t.type==='income'?'income':'expense', date:t.date, amount:Number(t.amount)||0, categoryId:catId, payMethod:t.payMethod||'', tags:Array.isArray(t.tags)?t.tags: String(t.tags||'').split(/[,\s]/).filter(Boolean), memo:t.memo||'', createdAt:t.createdAt||now, updatedAt:now};
            const exist = transactions.find(x=>x.id===id);
            await DB.putTransaction(tx);
            if(exist){ Object.assign(exist, tx); updated++; } else { transactions.push(tx); added++; }
          }
          alert('インポート完了: 追加 '+added+' / 更新 '+updated);
        }
        renderCategorySelects(); renderAll();
      }
      async function importCSV(text){
        const rows = parseCSV(text); if(rows.length===0){ alert('CSVが空です'); return; }
        const header = rows.shift().map(h=>h.toLowerCase());
        const idx = (name)=> header.indexOf(name);
        const iId=idx('id'), iType=idx('type'), iDate=idx('date'), iAmount=idx('amount'), iCategory=idx('category'), iPay=idx('paymethod'), iTags=idx('tags'), iMemo=idx('memo');
        const nameToId = new Map(categories.map(c=>[c.name,c.id]));
        let added=0, updated=0;
        for(const r of rows){
          if(r.length===1 && r[0]==='') continue;
          const id = r[iId] || uuid();
          const type = r[iType]==='income'?'income':'expense';
          const date = r[iDate];
          const amount = Math.max(0, Math.floor(Number(r[iAmount]||0)));
          const catId = nameToId.get(r[iCategory]) || categories[0]?.id;
          const payMethod = r[iPay]||'';
          const tags = (r[iTags]||'').split(/[\s,]/).filter(Boolean);
          const memo = r[iMemo]||'';
          const now = Date.now();
          const tx = {id,type,date,amount,categoryId:catId,payMethod,tags,memo,createdAt:now,updatedAt:now};
          const exist = transactions.find(x=>x.id===id);
          await DB.putTransaction(tx);
          if(exist){ Object.assign(exist, tx); updated++; } else { transactions.push(tx); added++; }
        }
        alert('インポート完了: 追加 '+added+' / 更新 '+updated);
        renderAll();
      }
      function parseCSV(text){
        const out=[]; let row=[]; let i=0; let s=text; let inQ=false; let cur='';
        while(i<s.length){
          const ch=s[i++];
          if(inQ){
            if(ch==='"'){
              if(s[i]==='"'){ cur+='"'; i++; }
              else { inQ=false; }
            } else { cur+=ch; }
          } else {
            if(ch===','){ row.push(cur); cur=''; }
            else if(ch==='\n'){ row.push(cur); out.push(row); row=[]; cur=''; }
            else if(ch==='"'){ inQ=true; }
            else if(ch==='\r'){ /* ignore */ }
            else { cur+=ch; }
          }
        }
        row.push(cur); out.push(row);
        return out;
      }

      // 起動
      init();
    </script>
  </body>
</html>

おわりに

いかがだったでしょうか。
このように、GitHub Copilotのような生成AIを活用することで、半自動で簡単なアプリを作成することができました。
実際の業務では、細かいドメイン知識や仕様の考慮が必要になるため、ここまで手放しで実装を行うことは稀です。
しかし、typoのチェックや実装方針のアドバイスなどを1次チェックとして常に行わせることができます。
このような機能は、洗練され続けており、今後の業務にも大いに役立つことでしょう。

弊社では、このような業務における生成AIの活用を率先して行っています。
今回ご紹介した機能は、GitHub Copilotの有償機能が大半ですが、弊社ではこういった生成AI利用に補助が出るので、比較的自由に使えます。
よって、移り変わりの激しい様々な最新ツールを試しながら、常に効率の良い業務のやり方を検討できます。

以上、生成AIを活用したAI駆動開発の紹介でした。