【家計簿アプリ】妻が既存のアプリは使いたくないと言うので、自分で作ることにした

# はじめに
我が家では、今まで家計簿はExcelで付けていました。
というのも、妻的に我が家の家計の情報をどこかのサーバーに上げるのが嫌で、既存のアプリを使いたがらなかったためでした。
しかしExcel管理に限界を感じ、夫婦で話し合う中で家計簿に下記のような要望が見えて来ました。
* とにかく情報を外部のサーバーやクラウドに上げたくない。
* 夫婦間で定期的にそれぞれの出費額が同じくらいになるように調整したいから、それぞれの累積収支を確認したい。
* 入力件数が多くなるので、それぞれで分担して入力するなど、効率化をはかりたい。

ということで、せっかくなので自分でアプリを実装してみることにしました。
この記事は、その取り組みとアプリの設計実装内容の共有を目的としたものになります。
ソースコードもGithubにて公開しますが、私個人が超絶時間の無い中で実装した物で、アプリとしては正直全然実装が足りていません。ご参照いただくのは嬉しいのですが、このままご使用されるのはお控えください。

https://github.com/y-yuu/kakeibon


# 実装方針
仕事と家庭の事情により、超絶時間がなく学習時間も限られていそうだったので、比較的勝手の分かるWebアプリの形式にすることにしました。
ただし、上記の通りクラウドを使えない縛りがあるので、localhostで動くWebアプリという形です。。
将来的に、このアプリが使えそうならElectron化したいなと思っています。
使用した技術は下記の通りです。
* Vue.js ( Composition API )
* TypeScript
* IndexedDB
* WebSocket

データは、IndexedDBを使用してブラウザ本体で管理することにしました。
構成を極力シンプルにするのが狙いです。
ロジックは全てTypeScriptで実装し、バックエンドにはほぼロジックを持たせない構成としました。

# アプリの全体像
前述のような理由から、データをローカルで管理しつつ、各々のPCで入力と集計結果の確認を出来る必要がありました。そのため、データを各々のPCで分散管理する構成とすることにしました。
具体的には、2人のPCそれぞれにこのアプリをインストール。データはそれぞれのPC内のDBにそれぞれで登録していきます。そして、アプリ間をWebSocketで通信出来るようにし、WebSocketで繋いだ時にそれぞれのDB内のデータを同期します。それぞれのアプリが繋がっている間は、片方で入力したデータはWebSocketにより瞬時にもう片方のアプリにも反映されるという形です。
<img width="400" alt="全体像.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/4a9f34f4-996a-587b-e5b0-df40065d112c.png">
こうすることで、DBをローカルで管理するという要件と、2人で分担して支出/収入を入力できるようにするという要件に対応出来るようにしました。

# アプリのUI
<img width="600" alt="UI全体.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/6e37ee4e-88b7-07e6-d394-3f4fbc0d4be5.png">

超絶時間が無い中で進めたので、見た目については荒めです。。
見た目のポイントとしては、それぞれの累積収支を確認出来るようにした点です。通算して、それぞれの累積収支額がいくらかを確認出来るようにし、夫婦間で調整する金額を計算しやすいようにしました。
<img width="400" alt="UI累積収支.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/9a312308-b7ba-3618-cf54-8795c30dfa99.png">
もう一つのポイントは、入力画面です。僕たちの使い方的に、ある程度レシートが溜まったタイミングでまとめて登録する形になりそうでした。
一件一件、全ての入力項目を設定するのはメチャクチャ大変そうです。なので、前の入力内容を引き継いで、異なる部分だけ適宜修正して入力出来るUIとしました。
↓まず1件目を登録し、「＋コピー」ボタンを押下
<img width="600" alt="UI登録１.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/ff3dd380-8a79-7118-3d95-ed2cc27c5163.png">
↓そうすると、1件目の内容を引き継いで2件目を表示する。
<img width="600" alt="UI登録２.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/aa0989ce-1884-b731-7556-f0115ff120af.png">
↓2件目の内容を適宜修正。これで入力量を削減する。
<img width="600" alt="UI登録３.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/233d36ff-dbaf-8f95-e730-96615536efdf.png">
これも、入力を効率化するという要件への対応です。
グラフも自作した方が自分のイメージする形にしやすいので、自作しています。
グラフに表示させる金額の中で絶対値が一番大きいデータをグラフ上の上限or下限のY座標に配置。そうすると、金額からY座標に変換する比率を得られるので、金額にその比率をかけて各頂点のY座標を算出しています。
↓カーソルバーが各年月のX座標に来たときは、フロートで金額を表示するようにしました。
<img width="600" alt="グラフ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/902be2ff-e0e9-8db2-62d8-f3b886915406.png">

# 実装の詳細
## データ
DBにはIndexedDBを使用し、アプリ内ではPiniaを使用してデータを管理します。
メインの、金額に関係するデータには下記の3種類としました。
* 明細データ（収入、支出一つ一つの金額）
* 項目別の集計データ（食費、日用品などの項目別の集計金額）
* 月毎の累積収支額（〜月時点で、集計開始年月から何円プラスorマイナスか？）

データの大きな流れは、下記のようにデータを登録すると集計処理を実行し、集計結果を画面に反映するという形です。
<img width="600" alt="データの流れ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/1a471d72-5636-2d97-0e88-f96e9dd9a562.png">

## IndexedDBでの集計処理
IndexedDBが集計処理に向いているかは、かなり微妙なところです。
IndexedDBのIDBKeyRangeでは、複数の条件を指定した抽出が全く出来ないわけではなさそうなのですが、RDBMSよりもかなり制限されています。[^1]このアプリでは、集計処理の元データとなる明細のオブジェクトストアに登録する値をネストしたオブジェクトとすることで、様々な切り口で集計できるようにしましたが、実装効率はメチャクチャ悪かったです。。

## 集計処理の計算方法
集計処理は画面から支出/収入の明細を登録した時に実行します。
明細を登録する度に、その明細の年月と同じ年月の項目別集計データを再計算します。
その後、項目別集計データを使用して、登録した明細の年月の一つ前の月の累積収支額データから直近のデータまでを再計算します。
<img width="600" alt="集計方法.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/73ff4537-65b8-26a6-a1be-c07357b5da17.png">
これにより、いわゆる月締め処理的なのは不要で、いつでも更新したい年月を更新出来るようにした形です。
↓累積収支データの計算処理抜粋。IndexedDBの呼び出しの流れで計算している。
```Typescript:Database.ts
// 一つ前の年月の累積収支データを取得
let preProfit = await getPreProfit();
// 更新した年月から最新の年月までの項目別集計データのリストを取得
const incomSpendings = await getMonthlyInComSpending( targetYearMonth, latestYearMonth );
        
// IndexedDBで、更新した年月から最新の年月までの累積収支オブジェクトストアのカーソルを取得
const db = await getDatabase();
const tran = db.transaction( [OBJ_STORE_TOTAL_PROFIT], "readwrite" );
const objs = tran.objectStore( OBJ_STORE_TOTAL_PROFIT );
const index = objs.index("yearMonth");
const req = index.openCursor( IDBKeyRange.bound( targetYearMonth, latestYearMonth ) );
req.onsuccess = async ( event ) => {
    const target = event.target as IDBRequest;
    const cursol = target.result as IDBCursorWithValue;

    if( cursol ) {
        // -- 取得した累積収支オブジェクトストアのレコード数分、下記の処理を繰り返す。 --

        const profit = cursol.value as ObjStoreTotalProfit;
        // 該当する年月の項目別集計データを取得。
        let thisMonth = incomSpendings.find( (e) => {
            return e.yearMonth === profit.yearMonth;
        });

        // 該当する項目別集計データがなければ、その月の集計金額は0として計算
        if( thisMonth === undefined ) {
            thisMonth = {
                yearMonth: "",
                incom1: 0,
                incom2: 0,
                spending1: 0,
                spending2: 0
            };
        }

        // その年月の累積収支の金額を計算
        profit.amount1 = preProfit.amount1 + ( thisMonth.incom1 - thisMonth.spending1 );
        profit.amount2 = preProfit.amount2 + ( thisMonth.incom2 - thisMonth.spending2 );
        // 再計算したデータでオブジェクトストアに更新
        objs.put( profit );
        // 前月累積収支データを更新して、次の年月の計算へ
        preProfit = profit;

        cursol.continue();
    }
}
```

## DB処理の構成とスケジュール
IndexedDB内の処理(get()メソッドなど)は別スレッドで実行してくれる[^2]ようですが、集計処理は一度DBから取り出して自前の実装で行っているので、そのままだとアプリと同じスレッドとなり、明細データの件数が増えた時にアプリの応答速度が低下する可能性が考えられました。
そこでまず、IndexedDBの呼び出しは、WebWorkerで別スレッドに実装します。
そして、そのWebWorkerにタスクを依頼する時は、一度タスクをリクエストキューに登録。IndexedDBはリクエストキューのタスクを順次実行していきます。実行後は、タスク依頼時に一緒に渡された結果取得用コールバック関数を実行して、アプリ側に結果を反映します。
<img width="600" alt="DBの構成.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/0830c6b8-f602-91f8-281c-b05b5ce4d1f1.png">
IndexedDBを呼び出しているWebWorkerへのリクエスト送信は、下記の形式の関数をリクエストの種類毎に定義し、これらの関数を呼び出すことでリクエストを実行しています。
```Typescript:DbRequestWindow.ts
// 集計タスクのリクエストを行う
// yearMonth: 集計対象の年月
// resultAction: 結果取得用コールバック関数
export const requestCalclate = ( yearMonth: string, resultAction: ( total: ObjStoreTotalByClassId ) => void ) => {

    // 結果取得用コールバック関数をキューに登録
    getCalcResultFunc.push({
        yearMonth: yearMonth,
        func: resultAction
    });
        
    // IndexedDBを呼び出しているWebWorkerへのリクエストフォーマット
    const req: DbRequest = {
        command: "calc",
        targetYearMonth: yearMonth,
        // -- 省略 --
    };
    
    // リクエストを送信
    dbWorker.postMessage( req );
}
```
DBに依頼するタスクで代表的なものは下記です。
* 収入/支出明細の登録
* 集計

この中で、集計処理の依頼は、収入/支出明細の登録を行う度に依頼が来る形になります。
そのままだと、例えば同じ年月の明細を複数登録した時は、余分に集計処理が走ることになり、データ件数が増えた時に集計データの反映が遅くなってしまいます。
そこで、同じ年月の集計処理の依頼が来た時は、集計のタスクを１つにまとめ、さらに集計処理のスケジュールを1番最後にずらすようにしました。これにより、集計処理の回数を最小限にする狙いです。
<img width="600" alt="タスクのスケジュール.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/f42e6db9-4921-d2b3-5bf0-59d11c92e882.png">

## データの同期
このアプリでは、それぞれのアプリ間をWebSocketで繋いでいる間はデータを同期します。
同期中は２つのアプリ間でデータを送り合うのですが、相手に送るデータは明細データのみです。このアプリでは明細データが他の金額データの元となっており、明細データのみを送り合い、他の集計データ等は各々のアプリ内で算出します。
<img width="400" alt="同期.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/120942e5-6e9d-c329-fab8-ff3cfe47a9f9.png">
集計データ等は、直接外部から登録せず常に明細データから算出するとすることで、整合性を取りやすくする狙いです。
（実際のデータの同期ではWebSocketを中継するサーバーを使用していますが、中継サーバーは特に作り込みが甘いためGithubにはアップしていません。。）

## データ同期のシーケンス
お互いのアプリをWebSocketで接続すると、まず交互にそれぞれ持っている明細データを全量送り合いそれぞれでマージをします。
その時、簡易的な状態遷移をしながら同期を進める形にしています。
<img width="400" alt="同期シーケンス.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3568680/994b71fa-5903-531c-e70c-210da1b2cae9.png">
この初回同期が終了した後は、明細データが登録される度に相手にも送るという形です。
明細データを送り合う量に無駄は多いですが、時間もないのでここは実装量を減らすことを優先しました。

## アプリ名
温かい目でお願いいたします。。

# 今後
まだまだ、だいぶ粗い実装ですが、まずはこれで運用していこうと思います。
今後は実際に使用していくなかで、実際の使い方に合わせてブラッシュアップしていけたら良いなと考えています。

[^1]:↓IDBKeyRangeの仕様。抽出条件に使用するインデックスが１つなら困らなそうだが、複数のインデックスを使用して抽出したい場合は工夫が必要。
https://developer.mozilla.org/ja/docs/Web/API/IDBKeyRange
[^2]:↓get()の仕様。別スレッドでとの記載がある。
https://developer.mozilla.org/ja/docs/Web/API/IDBObjectStore/get