QLITRE DIALY

HonoXで家計簿を作ってみた

2024年12月15日

この記事は Hono Advent Calendar 2024シリーズ1 の 15日目の記事です。
https://qiita.com/advent-calendar/2024/hono

思い返すと去年はこの日記サイトをHonoに置き換えた話を書きました。

日記サイトをHono x Cloudflareに置き換えた

今年はHonoXを使ってシンプルな個人用の家計簿アプリを作ってみたのでその紹介をします。

採用技術など

  • HonoX
  • Cloudflare Pages
  • Cloudflare D1
  • Firebase
  • react-chart-js-2
  • Tailwind CSS

アプリ概要

非常にシンプルな私が個人的に使っている家計簿アプリです。

支出、収入、資産をカテゴリ別に記録して月間収支を計算したり、資産額の推移を計算したりできます。ダッシュボード系のページではChart.jsでグラフを表示させています。

画面をいくつか紹介します。

トップページ。メニュー一覧を表示させています。

一覧系の画面です。この画面でデータの追加、編集、削除をします。

ちょっとこだわった点で、各データはモーダルで追加、削除ができます。

登録したデータの集計を行い、ダッシュボード系のメニューでグラフと共に表示させています。

月間収支が確認できたり…

資産状況と推移がカテゴリ別に確認できたり…

投資用口座入金履歴と投資用資産の比較をしてどのくらい含み益があるかを比較したりできます。

作ろうと思ったきっかけ

もともとPython Djangoで作った家計簿をWindowsローカルマシン上のIISサーバーで稼働させていました。

https://github.com/qlitre/django-mdb-kakeibo

こちらも内部的にChart.jsで動かしていて結構気に入っていて使い続けていたのですが、外出先からスマホでデータを登録できないことや、妻とデータを共有する際にわざわざ自分のパソコンを見てもらって…といった点に難がありました。

クラウド上にデプロイすれば解決するのでしょうけど、RDBが使えて無料で使えるDjangoのデプロイ環境があまり思い浮かばず。そんな中でこれってHonoXでCloudflareで動かしたら解決できないかな?と思ったのがきっかけです。

Cloudflare上にデプロイすれば、外出先からスマホでデータ登録できますし、妻とも共同で使えます。

データベースもD1というサービスがあります。確か5GBまでの無料枠が用意されていて、家計簿アプリは小規模な数字やテキスト保存しかしないので、ほぼ永続的に無料で使えると言っても過言ではありません。

個人で小さく使うにはかなりうってつけの構成なのではないか、という評価です。

技術的な話など

以下に技術的な話。実装をがんばったところを書いていきます。

グラフ表示部分

通常のHonoXのJSXレンダリングですとChart.jsとの共存が難しい?ような気がしていて、やりたいと思っていたものの、しばらく手をつけられておりませんでした。

そんななか、食べたリンゴの割合をHonoを使ってドーナッツグラフ化するという記事を見つけて、似たようなことができるかな?と思い手を動かし始めました。

Cloudflare Pages・Workers + Hono + React + Chart.js で食べたリンゴの割合をグラフ化してみた

レンダリングはReactを使っていてreact-chartjs-2を使っているみたいです。

HonoXではREADMEのBYORの章にある通り、レンダリングでReactを動作させることができます。

BYOR - Bring Your Own Renderer

// routes/_renderer.ts
import { reactRenderer } from '@hono/react-renderer'

export default reactRenderer(({ children, title }) => {
  return (
    <html lang='ja'>
      <head>
        <meta charSet='UTF-8' />
        <meta name='viewport' content='width=device-width, initial-scale=1.0' />
        {import.meta.env.PROD ? (
          <script type='module' src='/static/client.js'></script>
        ) : (
          <script type='module' src='/app/client.ts'></script>
        )}
        {import.meta.env.PROD ? (
          <link href='/static/assets/style.css' rel='stylesheet' />
        ) : (
          <link href='/app/style.css' rel='stylesheet' />
        )}
        {title ? <title>{title}</title> : ''}
      </head>
      <body>
        {children}
      </body>
    </html>
  )
})

これでroutes配下で普通にファイルを作っていくと内部的にはReactが動作するみたいです。

あとはクライアントコンポーネントが動作するislandsディレクトリにreact-chartjs-2でグラフを返すファイルを用意するとうまくグラフが返ってきました。例えば支出のパイチャートグラフ部分の実装はこんな感じです。

import type { FC } from 'react';
import type { SummaryItem } from '@/@types/dbTypes';
import { ArcElement, Legend, Tooltip, Chart as chartJs, ChartOptions } from 'chart.js';
import { Pie } from 'react-chartjs-2';

type Props = {
  items: SummaryItem[]
  colorMap: Record<number, string>
}

export const ExpensePieChart: FC<Props> = (props) => {
  chartJs.register(ArcElement, Tooltip, Legend);

  const options: ChartOptions<'pie'> = {
    plugins: {
      legend: {
        display: true,        // 凡例を表示
        position: 'top',    // 凡例の位置を指定
        labels: {
          boxWidth: 20,       // ラベルのボックスサイズ
          padding: 10,        // ラベル間の余白
        },
      },
    },
    responsive: true
  };

  const labels = []
  const amounts = []
  const colors: string[] = []
  for (let i = 0; i < props.items.length; i++) {
    const elm = props.items[i]
    const categoryName = elm.category_name
    const amount = elm.total_amount
    labels.push(categoryName)
    amounts.push(amount)
    colors.push(props.colorMap[elm.category_id])
  }

  const data = {
    labels: labels,
    datasets: [
      {
        label: '支出内訳',
        data: amounts,
        backgroundColor: colors,
        hoverOffset: 4,
      },
    ],
  };

  return (
    <div>
      <Pie data={data} options={options} />
    </div>
  );
};

認証周りについて

家計簿の場合は公開したくないデータもありますのでユーザー認証の実装が必要です。

今回はFirebaseを採用しました。

実は当初はsupabaseで認証を実装しようとして検証していました。その時に書いたエントリもあります。

「HonoXとsupabaseで簡易認証機能を実装する」

一通りやりたいことができることは確認していましたが、supabaseですと一週間ログインがないと、警告が入りプロジェクトが停止される仕様があります。

今回の家計簿アプリは想定しているユーザーは私と妻くらいなので、一週間ログインをしないことは起こり得そうです。

幸いFirebaseでもsupabaseとほぼ手間が変わらず認証の実装ができたのでそちらで動かしています。

手順をまとめます。まずはプロジェクトルートにFirebaseアプリを初期化するfirebase.tsを作成します。

import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { Context } from "hono";

export const auth = (c: Context) => {
    const firebaseConfig = {
        apiKey: c.env.FB_API_KEY,
        authDomain: c.env.FB_AUTH_DOMAIN,
        projectId: c.env.FB_PROJECT_ID,
        storageBucket: c.env.FB_STORAGE_BUCKET,
        messagingSenderId: c.env.FB_MESSAGE_SENDER_ID,
        appId: c.env.FB_APP_ID
    };
    const app = initializeApp(firebaseConfig);
    const _auth = getAuth(app);
    return _auth
}

これをログイン時に呼び出してログインします。

この時にHonoのsetCookieを使ってトークン情報を保存するようにしています。

import type { FC } from 'react'
import { createRoute } from 'honox/factory'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { signInWithEmailAndPassword } from 'firebase/auth'
import { setCookie } from "hono/cookie";
import { auth } from '@/firebase'

const schema = z.object({
    email: z.string().min(3).includes('@'),
    password: z.string().min(8),
});


type Data = {
    error?: Record<string, string[] | undefined>
    email?: string;
    password?: string;
}

export const LoginForm: FC<{ data?: Data }> = ({ data }) => {
    return (
        // 省略 ログインフォーム
    )
}

export default createRoute((c) => {
    return c.render(<LoginForm></LoginForm>)
})



export const POST = createRoute(
    zValidator('form', schema, (result, c) => {
        if (!result.success) {
            const { email, password } = result.data
            return c.render(
                <LoginForm data={{ email, password, error: result.error.flatten().fieldErrors }} />
            )
        }
    }),
    async (c) => {
        const _auth = auth(c)
        const { email, password } = c.req.valid('form')
        try {
            const data = await signInWithEmailAndPassword(_auth, email, password);
            if (data.user) {
                const idToken = await data.user.getIdToken();
                setCookie(c, 'firebase_token', idToken, {
                    httpOnly: true,
                    sameSite: 'strict'
                })
                return c.redirect('/auth', 303);
            }
        } catch (error) {
            // エラーメッセージを取得
            const errorMessage = (error as Error).message || 'ログインに失敗しました';
            return c.render(
                <LoginForm data={{ email, password, error: { login: [errorMessage] } }} />
            );
        }
        // ログイン失敗時に再度ログインページへリダイレクト
        return c.redirect('/login', 303);
    }
);

あとはこのトークンを使って検証するcheckauthFb.tsを作ります。

tokenを使ってユーザーが取得できるかどうかをログイン検証としています。ここのところはもう少し良い方法がある気がしますが、、。

import { Context } from "hono";
import { getCookie } from "hono/cookie";

export const checkauthFb = async (c: Context): Promise<boolean> => {
    try {
        // クッキーから `firebase_token` を取得
        const idToken = getCookie(c, 'firebase_token');
        if (!idToken) {
            console.log('No token found');
            return false;
        }

        // Firebase Authentication REST API エンドポイント
        const url = `https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${c.env.FB_API_KEY}`;

        // トークンの検証リクエストを送信
        const response = await fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ idToken }),
        });

        // レスポンスが正常でない場合はエラー
        if (!response.ok) {
            console.error('Invalid token response:', await response.text());
            return false;
        }
        interface FirebaseResponse {
            users: Array<{
                localId: string;
                email: string;
                emailVerified: boolean;
                displayName?: string;
            }>;
        }
        // ユーザー情報を取得
        const data: FirebaseResponse = await response.json();
        const user = data.users?.[0];
        if (!user) {
            console.log('No user data found');
            return false;
        }
        return true;
    } catch (error) {
        console.error('Token verification failed:', error);
    }

    // トークンが無効な場合
    return false;
};

あとは_middleware.tsで呼び出して認証をかけたいルートで検証します。

import { createRoute } from 'honox/factory';
import { createMiddleware } from 'hono/factory';
import { bearerAuth } from 'hono/bearer-auth';
import { checkauthFb } from '@/checkauthFb';

const authMiddleware = createMiddleware(async (c, next) => {
    if (c.req.path.startsWith('/auth')) {
        const isAuthenticated = await checkauthFb(c);
        if (isAuthenticated) {
            await next();
        } else {
            return c.redirect('/', 303);
        }
    } else if (c.req.path.startsWith('/api')) {
        const token = c.env.HONO_IS_COOL;
        const auth = bearerAuth({ token });
        return auth(c, next)
    } else {
        await next();
    }
});

export default createRoute(authMiddleware);

画面の表示はroutes/auth/配下で行い、Firebaseで認証。

データのCRUD関連はroutes/api/配下にまとめて、HonoのbearerAuthで認証しています。こっちにはFirebaseの認証はかけていません。

画面の表示とデータの取得認証は分けた方がのちのち拡張するときに便利かな?って思ったのでこういう構成にしました。

こうしておくと、簡単にデータを追加できるUIを別の方法でやりたい…みたいなときに小回りが利きそうです。

ジャストアイデアですが、頻繁に発生する支出の追加をモバイルデバイスからGoogle FormとかLINEとかでぱぱっと行えたら利便性があがりそうです。

つまづいたところ

reactレンダリングにすると、c.env.API_KEYのように書いてもcontextにうまくアクセスできない問題が出ました。

これも先人のZennのエントリに助けられた形で、wranglerのgetPlatformProxy APIをvite.config.tsで利用することで解決しました。理屈は良く分かってませんが、とりあえず動いています。

Hono and React full stack dev

工夫したところ

以下、工夫したところなど。

テーブルをなるべく同じ構成にする

以下のようにテーブル構成は極力揃えるようにしました。

CREATE TABLE IF NOT EXISTS asset_category (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    is_investment BOOLEAN DEFAULT 0,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS asset (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date TEXT NOT NULL,
    amount INTEGER NOT NULL,
    asset_category_id INTEGER NOT NULL,
    description TEXT,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now')),
    FOREIGN KEY (asset_category_id) REFERENCES asset_category(id) ON DELETE RESTRICT
);


CREATE TABLE IF NOT EXISTS expense_category (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS payment_method (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS expense (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date TEXT NOT NULL,
    amount INTEGER NOT NULL,
    expense_category_id INTEGER NOT NULL,
    payment_method_id INTEGER NOT NULL,
    description TEXT,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now')),
    FOREIGN KEY (expense_category_id) REFERENCES expense_category(id) ON DELETE RESTRICT,
    FOREIGN KEY (payment_method_id) REFERENCES payment_method(id) ON DELETE RESTRICT
);

CREATE TABLE IF NOT EXISTS income_category (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS income (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date TEXT NOT NULL,
    amount INTEGER NOT NULL,
    income_category_id INTEGER NOT NULL,
    description TEXT,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now')),
    FOREIGN KEY (income_category_id) REFERENCES income_category(id) ON DELETE RESTRICT
);

基本的には収入、支出、資産のデータにそれぞれカテゴリと紐づけができて、、という構成にしています。

一応ER図をmermaidで書いてみました。

あくまで自分が使うものですので、あまり凝ったこだわりは必要はありません。

テーブル構成を似たようなものにすることで、パーツの使いまわしが容易になったと思います。

例えば一覧系の画面のテーブルは収入、支出、資産すべて以下のTableコンポーネントで統一しています。

import type { FC, ReactNode } from 'react'
import type { TableHeaderItem } from '@/@types/common'

type Props = {
    headers: TableHeaderItem[]
    children: ReactNode
}

export const Table: FC<Props> = ({ headers, children }) => {
    return (
        <div className="mt-8 flow-root">
            <div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
                <div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
                    <div className="overflow-hidden border border-gray-300 rounded-lg shadow ring-1 ring-black ring-opacity-5">
                        <table className="min-w-full divide-y divide-gray-300">
                            <thead className="bg-gray-50">
                                <tr>
                                    {headers.map((item, i) => {
                                        return (
                                            <th scope="col" className={`py-4 px-6 text-${item.textPosition} text-sm font-semibold text-gray-900`} key={i}>
                                                {item.name}
                                            </th>
                                        )
                                    })}
                                </tr>
                            </thead>
                            {children}
                        </table>
                    </div>
                </div>
            </div>
        </div>
    )
}

データ取得APIを一本化する

当初は収入だったら/api/income/index.ts、支出だったら/api/expense/index.tsというようにテーブルごとにルートを生やして対応したSQL文を書いていました。

ただ、そうするとテーブルの数だけファイルが増えていってしまいメンテナンスが大変そうという予感がありました。

そこで、テーブルの構成もほぼ同じにしているので、/api/[endpoint]/index.tsのようなルート構成にして、動的にクエリを作成する…というのをやってみました。

この辺はかなりChatGPTに相談ながら実装を進めました。結果的に事前にテーブル定義ファイルを作成しておくことで動的なクエリ生成を実現しました。

type Join = {
    table: string;
    condition: string;
};

type SchemaEntry = {
    fields: string[];
    joinFields: string[];
    tableName: string;
    joins: Join[];
    requiredFields: string[];
    optionalFields: string[];
};

// スキーマ全体の型
type Schema = {
    [key: string]: SchemaEntry;
};

export const schema: Schema = {
    asset: {
        fields: ['id', 'date', 'amount', 'asset_category_id', 'description', 'created_at', 'updated_at'],
        joinFields: ['asset_category.name AS category_name', 'asset_category.is_investment AS is_investment'],
        tableName: 'asset',
        joins: [
            {
                table: 'asset_category',
                condition: 'asset.asset_category_id = asset_category.id'
            }
        ],
        requiredFields: ['date', 'amount', 'asset_category_id'],
        optionalFields: ['description']
    },
    asset_category: {
        fields: ['id', 'name', 'is_investment', 'created_at', 'updated_at'],
        joinFields: [],
        tableName: 'asset_category',
        joins: [],
        requiredFields: ['name'],
        optionalFields: ['is_investment']
    },
    fund_transaction: {
        fields: ['id', 'date', 'amount', 'description', 'created_at', 'updated_at'],
        joinFields: [],
        tableName: 'fund_transaction',
        joins: [],
        requiredFields: ['date', 'amount'],
        optionalFields: ['description']
    },
    expense: {
        fields: [
            'id', 'date', 'amount', 'expense_category_id', 'payment_method_id',
            'description', 'created_at', 'updated_at'
        ],
        joinFields: [
            'expense_category.name AS category_name',
            'payment_method.name AS payment_method_name'
        ],
        tableName: 'expense',
        joins: [
            {
                table: 'expense_category',
                condition: 'expense.expense_category_id = expense_category.id'
            },
            {
                table: 'payment_method',
                condition: 'expense.payment_method_id = payment_method.id'
            }
        ],
        requiredFields: ['date', 'amount', 'expense_category_id', 'payment_method_id'],
        optionalFields: ['description']
    },
    expense_category: {
        fields: ['id', 'name', 'created_at', 'updated_at'],
        joinFields: [],
        tableName: 'expense_category',
        joins: [],
        requiredFields: ['name'],
        optionalFields: []
    },
    payment_method: {
        fields: ['id', 'name', 'created_at', 'updated_at'],
        joinFields: [],
        tableName: 'payment_method',
        joins: [],
        requiredFields: ['name'],
        optionalFields: []
    },
    income: {
        fields: [
            'id', 'date', 'amount', 'income_category_id',
            'description', 'created_at', 'updated_at'
        ],
        joinFields: [
            'income_category.name AS category_name',
        ],
        tableName: 'income',
        joins: [
            {
                table: 'income_category',
                condition: 'income.income_category_id = income_category.id'
            },
        ],
        requiredFields: ['date', 'amount', 'income_category_id',],
        optionalFields: ['description']
    },
    income_category: {
        fields: ['id', 'name', 'created_at', 'updated_at'],
        joinFields: [],
        tableName: 'income_category',
        joins: [],
        requiredFields: ['name'],
        optionalFields: []
    },
};

こういう感じでテーブルごとに列定義やjoin句の条件などを定義して、tableNameを渡して動的にSELECT句を作成します。

import { schema } from '@/utils/sqlSchema'

export type TableName = keyof typeof schema;

export const generateSelectQuery = (tableName: TableName) => {
    const tableConfig = schema[tableName];
    // フィールドを作成
    let fields = tableConfig.fields.map(field => `${tableName}.${field}`).join(', ');
    const joinFields = tableConfig.joinFields.join(', ');
    if (joinFields) fields += `, ${joinFields}`

    let query = `SELECT ${fields} FROM ${tableName}`;
    if (tableConfig.joins && tableConfig.joins.length > 0) {
        tableConfig.joins.forEach(join => {
            query += ` JOIN ${join.table} ON ${join.condition}`;
        });
    }
    return query
}

あとはapiルートでこの関数を呼び出してendpointに応じてクエリを発行します。

import type { TableName } from '@/utils/sqlUtils';
import { createRoute } from 'honox/factory'
import {
    generateSelectQuery,
    getAndValidateFormData,
    generateInsertQuery,
    generateQueryBindValues,
    buildSqlWhereClause,
    buildSqlOrderByClause
} from '@/utils/sqlUtils'
import { schema } from '@/utils/sqlSchema';


export default createRoute(async (c) => {
    // url paramからテーブル名を取得
    const tableName = c.req.param('endpoint') as TableName;
    if (!(tableName in schema)) {
        return c.json({ error: 'Invalid endpoint' }, 400);
    }

    const db = c.env.DB;
    const _limit = c.req.query('limit') || '10';
    const _offset = c.req.query('offset') || '0';
    const limit = parseInt(_limit);
    const offset = parseInt(_offset);
    // endpointに応じてSELECT文を発行
    let query = generateSelectQuery(tableName)
    let countQuery = `SELECT COUNT(*) as totalCount FROM ${tableName}`;

    const filters = c.req.query('filters');
    if (filters) {
        const whereClause = buildSqlWhereClause(tableName, filters);
        query += ` ${whereClause}`;
        countQuery += ` ${whereClause}`;
    }

    const orderParams = c.req.query('orders');
    if (orderParams) {
        const orderByClause = buildSqlOrderByClause(tableName, orderParams);
        query += ` ${orderByClause}`;
    }

    query += ` LIMIT ? OFFSET ?`;
    try {
        const { results } = await db.prepare(query).bind(limit, offset).all();
        const totalCountResult = await db.prepare(countQuery).first<{ totalCount: number }>();

        const items = results ?? [];
        const totalCount = totalCountResult?.totalCount ?? 0;
        const pageSize = Math.ceil(totalCount / limit);

        const response = {
            contents: items,
            totalCount,
            limit,
            offset,
            pageSize,
        };

        return c.json(response, 200);
    } catch (error) {
        console.error('Error executing query', error);
        return c.json({ error: 'Failed to fetch data' }, 500);
    }
});

内部的にはWEHERE句やORDER句などもURLのクエリパラーメータに応じて行えるようにしています。

例えば/api/asset?filters=date[greater_equal]2024-11-01[and]date[less_equal]2024-11-30みたいなパラメータが渡されたときに、2024-11-01~2024-11-30のデータが返却されるようにWHERE句を動的に作っています。

このあたりのルールは私がよく使っているmicroCMSのAPIリクエスト方法を参考にています。

感想など

D1接続、認証機能、内部的にReact動作、グラフ生成など初めての経験が多かったのですが、また一つできることが増えたな、という感じがしてちょっとした達成感がありました。さすがHonoといったところか、デプロイ後もかなりきびきびと動いていて使いやすいです。妻にも支出登録メニューなどを使ってもらってるのですが、動作が早くて感動したと言っておりました。

家計簿は長く使えば使うほど楽しいです。これは2021年の9月から始めた僕の投資金額と保有価額の推移なんですけど、振り返ると投資を始めてから1年以上含み損を抱えてたのだな、ということが振りかえれたりして感慨深いです。これからどうなるか分かりませんが、投資金額に対して含み益が出てきているので、毎月記録をするのがちょっとした楽しみになっています。

このように家計簿は長く使えば使うほど記録がたまって楽しい。

プログラムもまさに使っているものをアップデートしていくのがきっと楽しそう。長く楽しみながら成長させていきたいと思います。ではでは。

ソースコード、セットアップ方法

最後に、ソースコードは全文githubに公開していますので共有します。

READMEでローカルのセットアップ方法についても書いています。もし興味がある方がいましたら家計簿をお楽しみください。

https://github.com/qlitre/honox-kakeibo