7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NSSOLAdvent Calendar 2024

Day 24

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

Last updated at Posted at 2024-12-23

はじめに

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

  • 何かWebアプリを作ってみたいけど、どうやって作ればいいかわからない
  • お金をかけずにWebアプリを作りたい

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

目次

開発に至った経緯

なぜ作るのか

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

どのように作るのか

作業コストの削減

個人開発においては、モチベーションの維持が非常に重要です。業務時間外の限られた時間で開発しなければならない一方で、モチベーションはいつ冷めるかわかりません。私自身もこれまで、モチベーションをうまく上げられず、本来短期で終えられるものに対して長期の開発期間を要してしまったことがあります。あるいは、アイデアはあってもどんな技術を使えばいいのか分からず、アイデアのまま終わってしまうということもありました。
そこで今回は、ChatGPTにコーディングを支援してもらい、作業時間を短縮させました。ChatGPTに叩き台のコードを生成してもらい、必要に応じて私が手直しor再度ChatGPTに指示するような形で開発を行いました。処理ロジックや私が疎いUIについても、ほぼChatGPTが動くものを作ってくれました。いい時代です。先に良さげな画面ができると、実際に使うイメージが湧くのでモチベーションが維持しやすいと感じます。
また、なる早で開発を終えるため、実装する機能についても最低限の機能(データの登録、集計)に留め、使用する中で必要に感じた機能を実装していくことにしました(MVPの考え方)。

運用コストの削減

一方で、支出を抑えるために家計簿をつけるのに、その運用にお金がかかっては本末転倒です。なので、データベースおよびアプリのホスティングには無料のサービスを使用しました。具体的には、DBには TiDB Cloud Serverless, ホスティングにはAWS Amplifyを使用しました。詳細については後述します。

家計簿アプリの要件

どんな家計簿アプリを作るか一旦整理してみました。

機能要件

  • 収支データの登録ができること
  • 収支データの集計ができること
    • 収支およびカテゴリごとに月々の金額を集計できること
    • 特定のタグがついたデータを含まない条件で集計できること
      • 突発的な出費を除外し、日常的にかかる出費を可視化するため

非機能要件

  • 運用費用が発生しないこと

技術スタック

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

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

使い方

  1. ログイン

  2. 「Create Cluster」からクラスターを作成

  3. 作成したクラスターを選択し、「SQL Editor」からテーブルを作成するSQLを実行

    実行した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
    );
    

DBとの接続構築

アプリがDBからデータを取得・保存できるようにするには、DBとアプリの接続を適切に構築する必要があります。
Next.jsではprismaというORMを使ってDBと接続するのがメジャーだと思います2が、今回はTiDB Data Serviceを使用することにしました。

TiDB Data Service

TiDB Data Serviceとは、TiDBに対してAPIエンドポイントを作成し、HTTPSリクエスト経由でTiDBのデータにアクセスできる機能です3。TiDBオタクとしては使ってみないわけにはいきません。

使い方

  1. ログイン

  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

    2. APIをデプロイ
      スクリーンショット 2024-12-22 18.09.10.png
      私が作成した一連のAPIを記載しておきます。

      GET /categories
      USE house_hold_account;
      SELECT * FROM `categories` WHERE `name` = ${name};
      
      GET /categories/all
      USE house_hold_account;
      SELECT * FROM `categories`;
      
      POST /categories
      USE house_hold_account;
      INSERT INTO `categories` (`name`) VALUES(${name});
      SELECT LAST_INSERT_ID() AS id;
      
      GET /tags
      USE house_hold_account;
      SELECT * FROM `tags` WHERE `name` = ${name};
      
      GET /tags/all
      USE house_hold_account;
      SELECT * FROM `tags`;
      
      POST /tags
      USE house_hold_account;
      INSERT INTO `tags` (`name`) VALUES(${name});
      SELECT LAST_INSERT_ID() AS id;
      
      GET /transactions
      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;
      
      GET /transactions/no_tag
      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;
      
      POST /transactions
      USE house_hold_account;
      INSERT INTO `transactions` (`amount`,`type`,`category_id`,`tag_id`,`memo`,`date`) 
      VALUES(${amount}, ${type}, ${category_id}, ${tag_id}, ${memo}, ${date});
      
      POST /transactions/no_tag
      USE house_hold_account;
      INSERT INTO `transactions` (`amount`,`type`,`category_id`,`memo`,`date`) 
      VALUES(${amount}, ${type}, ${category_id}, ${memo}, ${date});
      
  5. APIのエンドポイントURLを記録する。API Keyを作成し、Public Key, Private Keyを保管しておく
    スクリーンショット 2024-12-22 23.41.39.png

アプリケーション

先述の通り、ChatGPTに叩き台のコードを生成してもらい、必要に応じて私が手直し、ないし再度ChatGPTに指示を行いました。

Next.js

アプリの構築にはNext.jsを使用しました。Next.jsはReactのフレームワークで、多くのWebサイトで採用されています。ちょうど会社でNext.jsの研修を受けたばかりなので、実際に使ってみることにしました。
今回の開発では以下を選択しました。

  • 言語: JavaScript
  • ルーティング方式: Pages Router
  • CSS: Tailwind CSS

完成したコードの一部を記載します。

収支データ登録コンポーネント
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>
  );
}

収支データ集計コンポーネント
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>
  );
}

また、プロジェクト直下の.envファイルに以下を記載しておきます

API_BASE_URL=[取得したAPIエンドポイントURL]
API_PUBLIC_KEY=[取得したAPI Public Key]
API_SECRET_KEY=[取得したAPI Secret Key]

最終的に作成したコードはGitHubに公開しているので、適宜ご参照ください。

Webサイトに公開

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

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

Amplifyの使い方

あくまで一例です。

  1. ソースコードをGitHubに公開
  2. Amplifyで「新しいアプリを作成」を選択
  3. ソースコードリポジトリとしてGitHubを選択
  4. GitHubに対するアクセス許可を行い、目的のリポジトリ、ブランチを選択
  5. アプリケーションの設定を行う
    ※ 詳細設定にある環境変数項目はクライアントサイドの環境変数を追加するのに使用します。サーバーサイドの環境変数(今回だとAPIのエンドポイントURL, API Public Key, API Secret Key)は.envファイルに渡し直す必要があります(参考サイト)。
    そのため環境変数に追加したのち、環境変数を.envに渡し直すようにビルド設定ファイルを修正しました:
    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

集計画面①
スクリーンショット 2024-12-23 0.00.29.png
指定の年月の集計結果が表示されます。

集計画面②
スクリーンショット 2024-12-23 0.00.43.png
タグ「引越し」「以外」を指定しているので、引越しタグがついていないデータの集計結果が表示されています。

おわりに

冒頭述べた疑問に対する回答としては、

  • 何かWebアプリを作ってみたいけど、どうやって作ればいいかわからない -> LLMに頼ってみよう
  • お金をかけずにWebアプリを作りたい -> 今回紹介したサービスを参考にしてみてね

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

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

  1. https://x.com/hdtkkj/status/1869308637182939497

  2. prismaでTiDBと接続する場合は、このドキュメントをご参照のこと

  3. 2024年12月現在ベータ版として提供されている

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?