【無料】TiDB×Next.js×Amplifyで家計簿アプリを開発してみた

# はじめに

自作の家計簿アプリを極力コストを抑えて開発したので、開発手順や技術的知見を備忘録として記載します。ここで言う"コスト"とは作業コストと運用コストを指します。**開発にあまり時間をかけずに、かつ無料で運用できるWebアプリを構築した**ので、一例としてご紹介します。
- 何かWebアプリを作ってみたいけど、どうやって作ればいいかわからない
- お金をかけずにWebアプリを作りたい

といった方々には特に有益な記事になると信じています。

# 目次
- [はじめに](#はじめに)
- [目次](#目次)
- [開発に至った経緯](#開発に至った経緯)
  - [なぜ作るのか](#なぜ作るのか)
  - [どのように作るのか](#どのように作るのか)
    - [作業コストの削減](#作業コストの削減)
    - [運用コストの削減](#運用コストの削減)
- [家計簿アプリの要件](#家計簿アプリの要件)
  - [機能要件](#機能要件)
  - [非機能要件](#非機能要件)
- [技術スタック](#技術スタック)
- [各技術要素](#各技術要素)
  - [DB](#db)
    - [テーブル設計](#テーブル設計)
    - [TiDB Cloud Serverless](#tidb-cloud-serverless)
      - [使い方](#使い方)
  - [DBとの接続（API）](#dbとの接続api)
    - [TiDB Data Service](#tidb-data-service)
      - [使い方](#使い方-1)
  - [アプリケーション](#アプリケーション)
    - [Next.js](#nextjs)
  - [Webサイトに公開](#webサイトに公開)
    - [Amplifyの使い方](#amplifyの使い方)
- [完成したアプリ](#完成したアプリ)
- [おわりに](#おわりに)


# 開発に至った経緯
## なぜ作るのか
これまで家計簿をつけていなかったのですが、先日支出を見直す機会があり、これを機に**月々の収支をきちんと記録したい**と考えました。
家計簿アプリを使用するにあたって、すべての収支を記録できるのはもちろんのこと、引越しやふるさと納税、プレゼント(~~今日はクリスマスイブですね~~)といった**突発的な出費を除外し、日常的にかかる出費を可視化**できるアプリを探していました。いくつかApple Storeに公開されているアプリをDLしてみましたが、少なくとも無料で使用できる範囲では上記の機能を備えたアプリが見つからなかったので、いっそのこと自分で作ってしまおうと思い、開発に至りました（自分で作ったものなら継続できるだろうという希望的観測も込めて）。

## どのように作るのか
### 作業コストの削減
個人開発においては、**モチベーションの維持**が非常に重要です。業務時間外の限られた時間で開発しなければならない一方で、モチベーションはいつ冷めるかわかりません。私自身もこれまで、モチベーションをうまく上げられず、本来短期で終えられるものに対して長期の開発期間を要してしまったことがあります。あるいは、アイデアはあってもどんな技術を使えばいいのか分からず、アイデアのまま終わってしまうということもありました。
そこで今回は、**ChatGPTにコーディングを支援してもらい、作業時間を短縮**させました。ChatGPTに叩き台のコードを生成してもらい、必要に応じて私が手直しor再度ChatGPTに指示するような形で開発を行いました。

（処理ロジックや私が疎いUIについても、ほぼChatGPTが動くものを作ってくれました。いい時代です。先に良さげな画面ができると、実際に使うイメージが湧くのでモチベーションが維持しやすいと感じます。）

また、なる早で開発を終えるため、実装する機能についても**最低限の機能**（データの登録、集計）に留め、使用する中で必要に感じた機能を実装していくことにしました（MVPの考え方）。
### 運用コストの削減
一方で、支出を抑えるために家計簿をつけるのに、その運用にお金がかかっては本末転倒です。なので、**データベース, API, アプリのホスティングには無料の以下のサービスを使用しました**。
- DB: TiDB Cloud Serverless
- API: TiDB Data Service
- ホスティング: AWS Amplify (, Vercel)

詳細については後述します。

# 家計簿アプリの要件
どんな家計簿アプリを作るか一旦整理してみました。
## 機能要件
- 収支データの登録ができること
- 収支データの集計ができること
  - 収支およびカテゴリごとに月々の金額を集計できること
  - **特定のタグがついたデータを含まない条件で集計できること**
    - 突発的な出費を除外し、日常的にかかる出費を可視化するため

## 非機能要件
- 運用費用が発生しないこと

# 技術スタック
- Next.js
  - アプリケーションコード
- AWS Amplify
  - Webホスティング
- TiDB Cloud Serverless
  - DB
- TiDB Data Service
  - アプリとDBの接続用APIエンドポイント

全体の構成図はこちらです。
![archi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/420594/78befb46-6971-77a5-533e-ceab20154cc7.png)


# 各技術要素
## DB
### テーブル設計
機能要件をもとに、保持すべき情報をリストアップします。
- 金額
- 種別
  - 収入or支出
- カテゴリ
  - 食費、光熱費、交通費など
- タグ
  - 引越し、クリスマスプレゼントなど
- メモ
  - 追加で書き込みたい情報
- 日付
  - データの日付

上記をもとにテーブル設計を行います。カテゴリとタグは別テーブルに切り出します。

transactions テーブル

| カラム名    | 型                | 制約                                                   | 説明                       |
|------------|-------------------|--------------------------------------------------------|----------------------------|
| id         | INT               | PRIMARY KEY, AUTO_INCREMENT                            | トランザクションID          |
| amount     | DECIMAL(10, 2)    | NOT NULL                                               | 金額                        |
| type       | ENUM('支出', '収入')| NOT NULL                                               | 種別 ('支出' or '収入')    |
| category_id| INT               | FOREIGN KEY (categories(id))                           | カテゴリID                 |
| tag_id     | INT               | FOREIGN KEY (tags(id))                                  | タグID                     |
| memo       | TEXT              |                                                        | メモ                        |
| date       | DATE              | NOT NULL                                               | 日付                        |
| created_at | DATETIME          | DEFAULT CURRENT_TIMESTAMP                              | 作成日時                    |
| updated_at | DATETIME          | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP  | 更新日時                    |

categories テーブル

| カラム名    | 型           | 制約                                                   | 説明         |
|------------|--------------|--------------------------------------------------------|--------------|
| id         | INT          | PRIMARY KEY, AUTO_INCREMENT                            | カテゴリID   |
| name       | VARCHAR(255) | NOT NULL                                               | カテゴリ名   |
| created_at | DATETIME     | DEFAULT CURRENT_TIMESTAMP                              | 作成日時     |
| updated_at | DATETIME     | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP  | 更新日時     |

tags テーブル

| カラム名    | 型           | 制約                                                   | 説明         |
|------------|--------------|--------------------------------------------------------|--------------|
| id         | INT          | PRIMARY KEY, AUTO_INCREMENT                            | タグID       |
| name       | VARCHAR(255) | NOT NULL                                               | タグ名       |
| created_at | DATETIME     | DEFAULT CURRENT_TIMESTAMP                              | 作成日時     |
| updated_at | DATETIME     | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP  | 更新日時     |

### TiDB Cloud Serverless

DBには[TiDB Cloud Serverless](https://pingcap.co.jp/tidb-cloud-serverless/)を使用しました。そもそもTiDBとはMySQL互換をもつNewSQLデータベースで、今話題のmixi2でも使用されているという話があります[^1]。
TiDB Cloud Serverlessは、1クラスターに対して月あたり5GiBのストレージと5,000万RUを無料で利用できます（5クラスターまでが無料）。RUはTiDBが独自に定めている単位で、読み込みや書き込みなどで消費されます（[詳細はこちら](https://pingcap.co.jp/tidb-serverless-pricing-details/)）。個人で使用するレベルであればオーバーすることはほぼありません。またサーバレスという名の通り、ユーザはインフラ管理を意識する必要がありません。
無料で使えるDBaaSはあまり多くないのですが、TiDB Cloud Serverlessは非常に高性能で使い勝手がよく、おすすめのDBです（私も普段から愛用しています）。

#### 使い方
1. [ログイン](https://auth.tidbcloud.com/login?state=hKFo2SBPaENrMVhjVnBsQ2JTbUFRQWc2a1BFWHhHOGJwV0d0dKFupWxvZ2luo3RpZNkgOGVmQTVUZFk2QlNacXUtT0pjTDd0aXF0bGFvTjQ3UHqjY2lk2SA2SVp0aENmbVJLSVBFblFTVDhhRGJ0TTdTR2RNbmlSbA&client=6IZthCfmRKIPEnQST8aDbtM7SGdMniRl&protocol=oauth2&response_type=token%20id_token&redirect_uri=https://tidbcloud.com/auth_redirect?prev%3D/&scope=openid%20email&nonce=jqCeUCrD2kGa1DoiQcdn6AUGzJm0hxqL&auth0Client=eyJuYW1lIjoiYXV0aDAuanMiLCJ2ZXJzaW9uIjoiOS4xOS4xIn0%3D)
2. 「Create Cluster」からクラスターを作成
3. 作成したクラスターを選択し、「SQL Editor」からテーブルを作成するSQLを実行
    <details><summary>実行したSQL</summary>

    ```sql
    /* Enter "USE {database};" to start exploring your data.
    Press Command + I to try out AI-generated SQL queries or SQL rewrite using Chat2Query. */
    USE house_hold_account;

    CREATE TABLE transactions (
        id INT PRIMARY KEY AUTO_INCREMENT,
        amount DECIMAL(10, 2) NOT NULL,
        type ENUM('支出', '収入') NOT NULL,
        category_id INT,
        tag_id INT,
        memo TEXT,
        date DATE NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        FOREIGN KEY (category_id) REFERENCES categories(id),
        FOREIGN KEY (tag_id) REFERENCES tags(id)
    );

    CREATE TABLE categories (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(255) NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );

    CREATE TABLE tags (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(255) NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );
    ```
    </details>

## DBとの接続（API）
アプリがDBからデータを取得・保存できるようにするには、DBとアプリの接続を適切に構築する必要があります。
Next.jsでは[prisma](https://www.prisma.io/)というORMを使ってDBと接続するのがメジャーだと思います[^2]が、今回は TiDB Data Service というサービスを使用してAPIを作成することにしました。

### TiDB Data Service
[TiDB Data Service](https://docs.pingcap.com/ja/tidbcloud/data-service-overview)とは、TiDBに対してAPIエンドポイントを作成し、HTTPSリクエスト経由でTiDBのデータにアクセスできる機能です[^3]。TiDBオタクとしては使ってみないわけにはいきません。

#### 使い方
1. [ログイン](https://auth.tidbcloud.com/login?state=hKFo2SBPaENrMVhjVnBsQ2JTbUFRQWc2a1BFWHhHOGJwV0d0dKFupWxvZ2luo3RpZNkgOGVmQTVUZFk2QlNacXUtT0pjTDd0aXF0bGFvTjQ3UHqjY2lk2SA2SVp0aENmbVJLSVBFblFTVDhhRGJ0TTdTR2RNbmlSbA&client=6IZthCfmRKIPEnQST8aDbtM7SGdMniRl&protocol=oauth2&response_type=token%20id_token&redirect_uri=https://tidbcloud.com/auth_redirect?prev%3D/&scope=openid%20email&nonce=jqCeUCrD2kGa1DoiQcdn6AUGzJm0hxqL&auth0Client=eyJuYW1lIjoiYXV0aDAuanMiLCJ2ZXJzaW9uIjoiOS4xOS4xIn0%3D)
2. 左側のタブからData Serviceを選択
3. 「Create Data App」を選択し、「Link Data Sources」に作成したクラスターを選択、「Data App Name」に名称を入力
4. APIエンドポイントを作成
    1. APIを作成。SQL内の変数がAPIのリクエストパラメータに対応する。
        ![スクリーンショット 2024-12-22 17.49.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/420594/1823df62-02d4-f715-156d-e5a787b2db58.png)
    2. APIをデプロイ
        ![スクリーンショット 2024-12-22 18.09.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/420594/9c7b6906-a849-86b2-3d9c-8501b67a8fd7.png)
    私が作成した一連のAPIを記載しておきます。
        <details><summary>GET /categories</summary>
        
        ```sql
        USE house_hold_account;
        SELECT * FROM `categories` WHERE `name` = ${name};
        ```
        </details>

        <details><summary>GET /categories/all</summary>
        
        ```sql
        USE house_hold_account;
        SELECT * FROM `categories`;
        ```
        </details>

        <details><summary>POST /categories</summary>
        
        ```sql
        USE house_hold_account;
        INSERT INTO `categories` (`name`) VALUES(${name});
        SELECT LAST_INSERT_ID() AS id;
        ```
        </details>

        <details><summary>GET /tags</summary>
        
        ```sql
        USE house_hold_account;
        SELECT * FROM `tags` WHERE `name` = ${name};
        ```
        </details>

        <details><summary>GET /tags/all</summary>
        
        ```sql
        USE house_hold_account;
        SELECT * FROM `tags`;
        ```
        </details>

        <details><summary>POST /tags</summary>
        
        ```sql
        USE house_hold_account;
        INSERT INTO `tags` (`name`) VALUES(${name});
        SELECT LAST_INSERT_ID() AS id;
        ```
        </details>

        <details><summary>GET /transactions</summary>
        
        ```sql
        USE house_hold_account;

        SELECT 
            c.name AS category_name,
            SUM(CASE WHEN t.type = '収入' THEN t.amount ELSE 0 END) AS total_income,
            SUM(CASE WHEN t.type = '支出' THEN t.amount ELSE 0 END) AS total_expense
        FROM transactions t
        LEFT JOIN categories c ON t.category_id = c.id
        LEFT JOIN tags tg ON t.tag_id = tg.id
        WHERE YEAR(t.date) = ${year}
        AND MONTH(t.date) = ${month}
        AND (
            (${condition} = 'only' AND tg.name = ${tag})
            OR (${condition} = 'exclude' AND (tg.name IS NULL OR tg.name <> ${tag}))
        )
        GROUP BY c.name
        ORDER BY c.name;
        ```
        </details>

        <details><summary>GET /transactions/no_tag</summary>
        
        ```sql
        USE house_hold_account;

        SELECT 
            c.name AS category_name,
            SUM(CASE WHEN t.type = '収入' THEN t.amount ELSE 0 END) AS total_income,
            SUM(CASE WHEN t.type = '支出' THEN t.amount ELSE 0 END) AS total_expense
        FROM transactions t
        LEFT JOIN categories c ON t.category_id = c.id
        WHERE YEAR(t.date) = ${year}
        AND MONTH(t.date) = ${month}
        GROUP BY c.name
        ORDER BY c.name;
        ```
        </details>

        <details><summary>POST /transactions</summary>
        
        ```sql
        USE house_hold_account;
        INSERT INTO `transactions` (`amount`,`type`,`category_id`,`tag_id`,`memo`,`date`) 
        VALUES(${amount}, ${type}, ${category_id}, ${tag_id}, ${memo}, ${date});
        ```
        </details>

        <details><summary>POST /transactions/no_tag</summary>
        
        ```sql
        USE house_hold_account;
        INSERT INTO `transactions` (`amount`,`type`,`category_id`,`memo`,`date`) 
        VALUES(${amount}, ${type}, ${category_id}, ${memo}, ${date});
        ```
        </details>

5. APIのエンドポイントURLを記録する。API Keyを作成し、Public Key, Private Keyを保管しておく
![スクリーンショット 2024-12-22 23.41.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/420594/fe65a2ae-4163-bd75-3bbf-269e9ba87562.png)

## アプリケーション
先述の通り、ChatGPTに叩き台のコードを生成してもらい、必要に応じて私が手直し、ないし再度ChatGPTに指示を行いました。
### Next.js
アプリの構築には[Next.js](https://nextjs.org/)を使用しました。Next.jsはReactのフレームワークで、多くのWebサイトで採用されています。ちょうど会社でNext.jsの研修を受けたばかりなので、実際に使ってみることにしました。
今回の開発では以下を選択しました。
- 言語: JavaScript
- ルーティング方式: Pages Router
- CSS: Tailwind CSS

完成したコードの一部を記載します。
<details><summary>収支データ登録コンポーネント</summary>

```javascript
import { useState, useEffect } from 'react';

export default function TransactionForm() {
  const [form, setForm] = useState({
    type: 'expense', // 'expense'/'income'で管理、送信時に変換
    date: new Date().toISOString().split('T')[0],
    amount: '',
    categoriesList: [],
    tag: '',
    memo: '',
    tagsList: [],
  });

  // コンポーネントマウント時にカテゴリ・タグ一覧を取得
  useEffect(() => {
    const fetchData = async () => {
      try {
        // カテゴリ一覧取得
        const catRes = await fetch('/api/categories/all');
        const catData = await catRes.json();

        // tags一覧取得
        const tagRes = await fetch('/api/tags/all');
        const tagData = await tagRes.json();

        const categories = catData && catData.data.data.rows ? catData.data.data.rows.map((c) => c.name) : [];
        const tags = tagData && tagData.data.data.rows ? tagData.data.data.rows.map((t) => t.name) : [];

        setForm((prevForm) => ({
          ...prevForm,
          categoriesList: categories,
          tagsList: tags,
        }));
      } catch (error) {
        console.error('Failed to fetch categories/tags:', error);
      }
    };

    fetchData();
  }, []);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm({ ...form, [name]: value });
  };

  const handleTypeChange = (type) => {
    setForm({ ...form, type });
  };

  const addCategory = () => {
    const newCategory = prompt('新しいカテゴリを入力してください:');
    if (newCategory && !form.categoriesList.includes(newCategory)) {
      setForm((prevForm) => ({
        ...prevForm,
        categoriesList: [...prevForm.categoriesList, newCategory],
        category: newCategory,
      }));
    }
  };

  const addTag = () => {
    const newTag = prompt('新しいタグを入力してください:');
    if (newTag && !form.tagsList.includes(newTag)) {
      setForm((prevForm) => ({
        ...prevForm,
        tagsList: [...prevForm.tagsList, newTag],
        tag: newTag,
      }));
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    // 必須項目のチェック
    if (!form.amount || !form.category || !form.date) {
      alert('必須項目（「金額」「種別（カテゴリ）」「日付」）が未入力です。');
      return;
    }

    // type変換: フロントendで'income'/'expense' => DB用に'収入'/'支出'
    const sendType = form.type === 'income' ? '収入' : '支出';

    const response = await fetch('/api/transactions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        amount: form.amount,
        type: sendType,
        categoryName: form.category,
        tagName: form.tag,
        memo: form.memo,
        date: form.date
      }),
    });

    if (response.ok) {
      alert('登録しました！');
      // ここで必要ならフォームリセット可能
      setForm((prev) => ({
        ...prev,
        amount: '',
        category: '',
        tag: '',
        memo: '',
      }));
    } else {
      alert('登録に失敗しました。');
    }
  };

  return (
    <form 
      onSubmit={handleSubmit} 
      className="space-y-2 text-sm max-w-md w-full mx-auto p-4"
      style={{ minHeight: '100vh' }} // 必要に応じて高さ調整
    >
      {/* 収入/支出の選択 */}
      <div className="flex justify-center space-x-2">
        <button
          type="button"
          onClick={() => handleTypeChange('income')}
          className={`px-4 py-2 rounded ${
            form.type === 'income' ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-700'
          }`}
        >
          収入
        </button>
        <button
          type="button"
          onClick={() => handleTypeChange('expense')}
          className={`px-4 py-2 rounded ${
            form.type === 'expense' ? 'bg-red-500 text-white' : 'bg-gray-200 text-gray-700'
          }`}
        >
          支出
        </button>
      </div>

      {/* 金額 */}
      <div>
        <label className="block text-xs font-medium text-gray-700">金額*</label>
        <input
          type="number"
          name="amount"
          value={form.amount}
          onChange={handleChange}
          className="w-full px-3 py-1 border rounded-md"
        />
      </div>

      {/* 種別（カテゴリ） */}
      <div>
        <label className="block text-xs font-medium text-gray-700">カテゴリ*</label>
        <div className="flex items-center space-x-2">
          <select
            name="category"
            value={form.category}
            onChange={handleChange}
            className="w-full px-3 py-2 border rounded-md"
          >
            <option value="">選択してください</option>
              {form.categoriesList.map((category, index) => (
                <option key={index} value={category}>
                  {category}
                </option>
              ))}
          </select>
          <button
            type="button"
            onClick={addCategory}
            className="flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-500 rounded hover:bg-blue-600"
            style={{ minWidth: '70px', height: '40px' }}
          >
            追加
          </button>
        </div>
      </div>

      {/* タグ */}
      <div>
        <label className="block text-xs font-medium text-gray-700">タグ</label>
        <div className="flex items-center space-x-2">
          <select
            name="tag"
            value={form.tag}
            onChange={handleChange}
            className="w-full px-3 py-2 border rounded-md"
          >
            <option value="">選択してください</option>
            {form.tagsList.map((tag, index) => (
              <option key={index} value={tag}>
                {tag}
              </option>
            ))}
          </select>
          <button
            type="button"
            onClick={addTag}
            className="flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-500 rounded hover:bg-blue-600"
            style={{ minWidth: '70px', height: '40px' }}
          >
            追加
          </button>
        </div>
      </div>

      {/* メモ */}
      <div>
        <label className="block text-xs font-medium text-gray-700">メモ</label>
        <textarea
          name="memo"
          value={form.memo}
          onChange={handleChange}
          className="w-full px-3 py-1 border rounded-md"
          rows="1"
          style={{ resize: 'none' }}
        />
      </div>

      {/* 日付 */}
      <div>
        <label className="block text-xs font-medium text-gray-700">日付*</label>
        <input
          type="date"
          name="date"
          value={form.date}
          onChange={handleChange}
          className="w-full px-3 py-1 border rounded-md"
        />
      </div>

      {/* 登録ボタン */}
      <button className="w-full px-4 py-2 text-white bg-green-500 rounded hover:bg-green-600">
        登録
      </button>
    </form>
  );
}

```
</details>

<details><summary>収支データ集計コンポーネント</summary>

```javascript
import { useEffect, useState } from 'react';
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  BarElement,
  Title,
  Tooltip,
  Legend,
  ArcElement
} from 'chart.js';
import { Pie } from 'react-chartjs-2';

ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement);

export default function Chart() {
  const now = new Date();
  const currentYear = now.getFullYear();
  const currentMonth = now.getMonth() + 1;

  const [data, setData] = useState(null);
  
  const [year, setYear] = useState(String(currentYear));
  const [month, setMonth] = useState(String(currentMonth));
  const [tags, setTags] = useState([]);
  const [selectedTag, setSelectedTag] = useState('');
  const [tagCondition, setTagCondition] = useState('only'); 

  useEffect(() => {
    const fetchTags = async () => {
      try {
        const tagRes = await fetch('/api/tags/all');
        const tagData = await tagRes.json();
        const tags = tagData && tagData.data?.data?.rows ? tagData.data.data.rows.map((t) => t.name) : [];
        setTags(tags);
      } catch (error) {
        console.error('Failed to fetch tags:', error);
      }
    };

    fetchTags();
  }, []);

  useEffect(() => {
    const fetchData = async () => {
      const query = new URLSearchParams({
        year,
        month,
        tag: selectedTag,
        condition: tagCondition
      });

      try {
        const res = await fetch('/api/transactions?' + query.toString());
        const json = await res.json();

        setData({
          categories: json.categories || ["未分類"],
          income: json.income || [0],
          expense: json.expense || [0],
        });
      } catch (error) {
        console.error('Failed to fetch transaction data:', error);
      }
    };

    fetchData();
  }, [year, month, selectedTag, tagCondition]);

  if (!data) return <div>Loading...</div>;

  const colors = [
    'rgba(75, 192, 192, 0.6)',
    'rgba(255, 99, 132, 0.6)',
    'rgba(255, 206, 86, 0.6)',
    'rgba(54, 162, 235, 0.6)',
    'rgba(153, 102, 255, 0.6)',
    'rgba(201, 203, 207, 0.6)',
  ];

  // カテゴリごとに色を割り当てる
  const assignColors = (cats) => cats.map((_, i) => colors[i % colors.length]);
  const incomeColors = assignColors(data.categories);
  const expenseColors = assignColors(data.categories);

  const incomeChartData = {
    labels: data.categories,
    datasets: [
      {
        data: data.income,
        backgroundColor: incomeColors,
      },
    ],
  };

  const expenseChartData = {
    labels: data.categories,
    datasets: [
      {
        data: data.expense,
        backgroundColor: expenseColors,
      },
    ],
  };

  const pieOptions = {
    responsive: true,
    maintainAspectRatio: false,
    layout: { padding: 10 },
    plugins: {
      legend: {
        display: false,
      },
      title: {
        display: false
      }
    },
  };

  const handleTagConditionChange = (e) => {
    setTagCondition(e.target.value);
  };

  // 凡例用のカテゴリ一覧（重複を除く）
  const allCats = new Set(data.categories);
  const uniqueCategories = Array.from(allCats);
  const legendColors = uniqueCategories.map((_, i) => colors[i % colors.length]);

  // 合計収入・合計支出を計算
  const totalIncome = data.income.reduce((acc, val) => acc + Number(val), 0);
  const totalExpense = data.expense.reduce((acc, val) => acc + Number(val), 0);

  return (
    <div className="w-full flex flex-col items-center p-4 space-y-4">
      <div className="max-w-md w-full p-4 bg-white rounded-md shadow-md space-y-4">
        <div className="flex space-x-2">
          <div className="flex flex-col">
            <label className="text-xs font-medium text-gray-700">年</label>
            <select 
              className="border rounded px-2 py-1"
              value={year}
              onChange={(e) => setYear(e.target.value)}
            >
              <option value="2023">2023年</option>
              <option value="2024">2024年</option>
              <option value="2025">2025年</option>
            </select>
          </div>
          <div className="flex flex-col">
            <label className="text-xs font-medium text-gray-700">月</label>
            <select 
              className="border rounded px-2 py-1"
              value={month}
              onChange={(e) => setMonth(e.target.value)}
            >
              {Array.from({length: 12}, (_, i) => i + 1).map(m => (
                <option key={m} value={m}>{m}月</option>
              ))}
            </select>
          </div>
        </div>

        <div className="flex flex-col">
          <label className="text-xs font-medium text-gray-700">タグ</label>
          <div className="flex space-x-2 items-center">
            <select 
              className="border rounded px-2 py-1 w-full"
              value={selectedTag}
              onChange={(e) => setSelectedTag(e.target.value)}
            >
              <option value="">選択してください</option>
              {tags.map((tag, idx) => (
                <option key={idx} value={tag}>{tag}</option>
              ))}
            </select>
            <div className="flex items-center space-x-1 text-xs">
              <label className="flex items-center space-x-1">
                <input 
                  type="radio" 
                  name="tagCondition" 
                  value="only" 
                  checked={tagCondition === 'only'}
                  onChange={handleTagConditionChange}
                />
                <span>のみ</span>
              </label>
              <label className="flex items-center space-x-1">
                <input 
                  type="radio" 
                  name="tagCondition" 
                  value="exclude"
                  checked={tagCondition === 'exclude'}
                  onChange={handleTagConditionChange}
                />
                <span>以外</span>
              </label>
            </div>
          </div>
        </div>
      </div>

      {/* カスタム凡例 */}
      <div className="max-w-2xl w-full bg-white p-4 rounded-md shadow-md space-y-2">
        <div className="flex justify-center space-x-4">
          {uniqueCategories.map((cat, i) => (
            <div key={i} className="flex items-center space-x-1">
              <div style={{ width: '12px', height: '12px', backgroundColor: legendColors[i], borderRadius: '2px' }}></div>
              <span className="text-xs">{cat}</span>
            </div>
          ))}
        </div>
        
        <div className="max-w-2xl w-full bg-white p-4 rounded-md shadow-md flex justify-around items-center" 
             style={{ overflow: 'visible' }}>
          <div className="flex flex-col items-center space-y-2" style={{ width: '45%', overflow: 'hidden' }}>
            <h3 className="text-center text-sm font-medium mb-2">収入</h3>
            <p className="text-sm font-medium">{totalIncome.toLocaleString()}円</p> {/* 収入合計額表示 */}
            <div className="relative" style={{ width: '100%', maxWidth: '200px', height: 'auto' }}>
              <Pie data={incomeChartData} options={pieOptions} />
            </div>
          </div>
          <div className="flex flex-col items-center space-y-2" style={{ width: '45%', overflow: 'hidden' }}>
            <h3 className="text-center text-sm font-medium mb-2">支出</h3>
            <p className="text-sm font-medium">{totalExpense.toLocaleString()}円</p> {/* 支出合計額表示 */}
            <div className="relative" style={{ width: '100%', maxWidth: '200px', height: 'auto' }}>
              <Pie data={expenseChartData} options={pieOptions} />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
```
</details>

また、プロジェクト直下の.envファイルに以下を記載しておきます
```
API_BASE_URL=[取得したAPIエンドポイントURL]
API_PUBLIC_KEY=[取得したAPI Public Key]
API_SECRET_KEY=[取得したAPI Secret Key]
```
最終的に作成したコードは[GitHub](https://github.com/rindo-610/household-account-app)に公開しているので、適宜ご参照ください。

## Webサイトに公開

Webサイトのホスティング環境には[AWS Amplify](https://aws.amazon.com/jp/amplify/)を使用しました。AmplifyはNext.jsなどで作成したプロジェクトをWebに公開する環境を提供しているサービスです。Amplifyの利用料金は通常有料ですが、AWSアカウント作成後12ヶ月間は[一定の条件下](https://aws.amazon.com/jp/amplify/pricing)で無料で使用することができます。こちらも個人利用レベルであればオーバーすることはほぼありません。

ちなみに、12ヶ月間の無料期間が過ぎたあとは[Vercel](https://vercel.com/)または[Netlify](https://www.netlify.com/)に乗り換える予定です。今回はAmplifyを使ってみたかったという理由で採用しましたが、乗り換えの手間を考えるのであれば初めからこれらを選択するのも一つの手だと思います。

### Amplifyの使い方
あくまで一例です。
1. ソースコードをGitHubに公開
2. Amplifyで「新しいアプリを作成」を選択
3. ソースコードリポジトリとしてGitHubを選択
4. GitHubに対するアクセス許可を行い、目的のリポジトリ、ブランチを選択
5. アプリケーションの設定を行う
   ※ 詳細設定にある環境変数項目はクライアントサイドの環境変数を追加するのに使用します。サーバーサイドの環境変数（今回だとAPIのエンドポイントURL, API Public Key, API Secret Key）は`.env`ファイルに渡し直す必要があります（[参考サイト](https://zenn.dev/k_log24/articles/ed344296b78bb6)）。
   そのため環境変数に追加したのち、環境変数を`.env`に渡し直すようにビルド設定ファイルを修正しました：
   ```yaml
   build:
      commands:
        - echo "API_BASE_URL=$API_BASE_URL" >> .env
        - echo "API_PUBLIC_KEY=$API_PUBLIC_KEY" >> .env
        - echo "API_SECRET_KEY=$API_SECRET_KEY" >> .env
        - npm run build
   ```
6. 「保存してデプロイ」を選択
7. デプロイ完了後、表示されるドメインにアクセス

# 完成したアプリ
登録画面
![スクリーンショット 2024-12-23 0.00.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/420594/3b33ae32-eac7-be0a-7e13-237948e2796d.png)

集計画面①
![スクリーンショット 2024-12-23 0.00.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/420594/35d8fc1d-da29-6307-4afb-65971f582dd0.png)
指定の年月の集計結果が表示されます。

集計画面②
![スクリーンショット 2024-12-23 0.00.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/420594/d13326aa-edeb-1900-bbf3-2b89fda4357a.png)
タグ「引越し」「以外」を指定しているので、引越しタグがついていないデータの集計結果が表示されています。

# おわりに
冒頭述べた疑問に対する回答としては、
- 何かWebアプリを作ってみたいけど、どうやって作ればいいかわからない -> **LLMに頼ってみよう**
- お金をかけずにWebアプリを作りたい -> **今回紹介したサービスを参考にしてみてね**

といったところでしょうか。

今回のアプリは**企画を含めて20時間ほどで完成しました**。この内容をアドベントカレンダーに投稿すると決めたことも、モチベーション維持に有効でした。やはり〆切が決まっていると捗ります。
次のアップデートでは、ユーザ認証や、複数タグを除外条件に指定した集計ができるようにしたいです。
なお、今回使用したサービスは主に私の興味ドリブンで選定したものであり、ベストプラクティスとは限らないことにご留意ください。


[^1]: https://x.com/hdtkkj/status/1869308637182939497
[^2]: prismaでTiDBと接続する場合は、[このドキュメント](https://docs.pingcap.com/ja/tidbcloud/dev-guide-sample-application-nodejs-prisma)をご参照のこと
[^3]: 2024年12月現在ベータ版として提供されている