はじめに
YouTubeには節約術や貯金のコツの動画がたくさんあります。どれも参考になりますが、見終わったあとに残るのはこんな疑問でした。
- この方法、自分の収入や暮らし方にも当てはまるの?
- たくさんある中で、結局何から始めればいいの?
かといって、FP(ファイナンシャルプランナー)に相談するのはハードルが高い。費用も気になるし、家計の中身を人に見せるのは恥ずかしい。
そこで、ユーザーをヒアリングして、その回答と支出の記録に応じてアドバイスが変わる家計改善アプリを、Chrome拡張機能として作りました。
この記事では、作ったものの紹介と、実装で工夫した点(Manifest V3の制約、条件分岐だけで作る提案ロジック、端末内保存のデータ設計)を解説します。
作ったもの:家計改善ノート
| 項目 | 内容 |
|---|---|
| 形式 | Chrome拡張機能(Manifest V3) |
| 技術 | 素の HTML / CSS / JavaScript(ES Modules)。ビルド不要・外部ライブラリなし |
| 保存先 |
chrome.storage.local(端末内のみ。外部送信なし) |
| 権限 |
storage / alarms / notifications の3つだけ |
| 規模 | 22ファイル、約2,900行 |
主な機能
- ツールバーのポップアップから、金額とカテゴリを押すだけで支出を記録
- 給料日始まりの「1か月」で集計し、今日あといくら使えるかを表示
- プロフィールとアンケートの回答、支出の記録から7つのタイプに分類
- 「通信費が月約8,000円」のように、本人の数字を根拠に改善提案を表示
- 給料日の翌日に月1回のふりかえりを通知し、提案を更新
設計のポイント
1. 「属性」と「アンケート」の役割を分けて、同じことを二度聞かない
ヒアリングは2種類に分けました。
| 種類 | 何を聞くか | 例 |
|---|---|---|
| 属性(プロフィール) | 事実として決まっていて、めったに変わらないもの | 年齢層、雇用形態、住居形態、手取りの範囲、給料日 |
| アンケート | 考え方・習慣・満足度、毎月変わりうる金額 | 固定費の内訳、貯金の目的、衝動買いの頻度 |
属性の値は、アンケートの出し分けに使います。例えば住居形態が「実家」なら、家賃の質問は「家に入れているお金」に文言が変わります。車を持っていない人には、車関連費の欄が出ません。
答えにくい収入は、金額ではなく範囲で選ぶ方式にして、「答えない」も選べるようにしました。「答えない」を選んだ場合は、貯蓄率を計算せず、固定費比率を「固定費 ÷ 支出全体」で判定します。
2. タイプ分類はAIを使わず、条件分岐だけで作る
個人開発で保守しやすく、提案の理由を説明できるように、分類は上から順に判定する条件分岐にしました。
// ===== タイプ分類(上から判定し、当てはまった最初の2つを主・副にする) =====
export function classify(ctx) {
const { ind, answers: a } = ctx;
const trig = a.q12_triggers || [];
const list = [];
if (ind.emergencyMonths != null && ind.emergencyMonths < ind.targetMonths) list.push('E');
if (ind.fixedRatio >= 0.45 || (ind.housingRatio != null && ind.housingRatio >= ind.housingThreshold)) list.push('A');
if ((a.q11_impulse || 0) >= 4 && trig.some((t) => ['stress', 'sale', 'latenight_ec'].includes(t))) list.push('C');
if (ind.smallCount >= 30 || (trig.includes('convenience') && ind.convenienceMonthly >= 10000)) list.push('B');
if (['leftover', 'none'].includes(a.q9_method) && (ind.savingsRate == null || ind.savingsRate < 0.10)) list.push('D');
if (!list.length && ind.savingsRate != null && ind.savingsRate >= 0.20) list.push('F');
if (!list.length) list.push('G');
return { primary: list[0], secondary: list[1] || null, provisional: ind.provisional };
}
| タイプ | 判定の考え方 |
|---|---|
| E:防衛資金不足型 | 貯金が生活費の3か月分(正社員以外は6か月分)に足りない |
| A:固定費ヘビー型 | 固定費が手取りの45%以上、または住居費が30%以上(首都圏は33%) |
| C:衝動・発散型 | 予定外の買い物が多く、きっかけがストレス・セール・夜のネット通販 |
| B:ちょこちょこ型 | 1,000円未満の支出が月30回以上 |
| D:仕組みなし型 | 「余ったら貯める」で、貯蓄率が10%未満 |
| F:順調・次の一手型 | 上のどれでもなく、貯蓄率が20%以上 |
| G:様子見型 | それ以外 |
3. 提案は「本人の数字」と「価値観」で並べ替える
提案は「対応タイプ・種類(削減型/仕組み化型)・カテゴリ・想定効果額・本文」で定義し、本人の数字を埋め込みます。
並び順のスコアには、アンケートの価値観を反映しています。
for (const s of collectCandidates(ctx)) {
// 満足度の高いカテゴリは削減提案の対象外(ただし先月後悔したものは対象に戻す)
if (s.kind === 'cut' && s.category && protectedCats.includes(s.category) && !regret.includes(s.category)) continue;
const state = st[s.id];
if (state?.status === 'dismissed' && state.until > ctx.today) continue;
let score = s.types.includes(ctx.cls.primary) ? 100 : s.types.includes(ctx.cls.secondary) ? 50 : 0;
score += Math.min((s.effect || 0) / 100, 50) + s.boost;
// 我慢が苦手な人には仕組み化型、平気な人には削減型を優先
if (resist >= 4 && s.kind === 'system') score += 30;
if (resist <= 2 && s.kind === 'cut') score += 30;
list.push({ ...s, score, status: state?.status === 'tried' ? 'tried' : 'active' });
}
- 「お金を使うと満足度が高いもの」に選んだカテゴリ(例:趣味)は、削減提案から外します
- 「節約で好きなことを減らすのは苦痛」と答えた人には、先取り貯金などの仕組み化型を優先します
- 「自分には合わない」を押した提案は90日間表示しません
- 「試してみる」を押した提案は、翌月の実績と比べて「前の月より◯円減」と表示します
使うほど、その人に合う提案だけが残っていく仕組みです。
4. 給料日始まりの「家計月」
会社員の実感に合わせて、給料日から次の給料日の前日までを1か月として集計します。月末払いや、31日がない月にも対応しています。
// その月の家計月の開始日(給料日が月末や31日でも月の日数に丸める)
function startDay(y, m, payday) {
const last = new Date(y, m, 0).getDate();
return payday === 'end' ? last : Math.min(Number(payday) || 1, last);
}
export function getPeriodForDate(s, payday) {
const [y, m, d] = s.split('-').map(Number);
const p = `${y}-${pad(m)}`;
return d >= startDay(y, m, payday) ? p : shiftPeriod(p, -1);
}
支出データは暦月で保存し、家計月の計算は読み出し時に行います。こうしておくと、ユーザーが給料日を変えてもデータを移し替える必要がありません。
Manifest V3で工夫したところ
権限は3つだけ。ホスト権限はなし
{
"manifest_version": 3,
"permissions": ["storage", "alarms", "notifications"],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'none'"
}
}
-
host_permissions・content_scriptsは定義していません。Webページの内容を一切読みません -
tabs権限がなくても、chrome.tabs.createで拡張機能内のページは開けます - エクスポートは Blob と
<a download>で済むので、downloads権限も不要です
connect-src 'none' で通信そのものを禁止しています。「外部に送信しない」を、コードの書き方ではなく仕組みで保証できます。家計のデータを扱うアプリなので、ここは特にこだわりました。
「毎月○日」の通知は、1日1回の判定で作る
chrome.alarms には「毎月25日」のようなカレンダー指定がありません。PCが閉じていた日を取りこぼさないためにも、1日1回起きて「実施日を過ぎていて未回答か」を判定する方式にしました。
async function setupAlarms() {
// 1日1回、月次アンケートの実施日かどうかを判定する(暦の「毎月○日」は指定できないため)
if (!(await chrome.alarms.get('dailyCheck'))) {
chrome.alarms.create('dailyCheck', { delayInMinutes: 1, periodInMinutes: 1440 });
}
const { settings } = await getMany(['settings']);
await scheduleReminder(settings);
}
通知は1つの家計月につき最大2回(予定日と、その3日後)までにして、しつこくならないようにしています。未回答の間は、ツールバーのアイコンに「!」のバッジを出します。setBadgeText は追加の権限なしで使えます。
サービスワーカーはいつ止まるかわかりません。イベントリスナーはトップレベルで同期的に登録し、状態はすべて chrome.storage に置きます。setTimeout に頼ると、ワーカーが止まった時点でタイマーが消えます。
支出は「月ごとのキー」に分けて保存する
chrome.storage は値をまるごと書き換えます。そのため、1つの配列に何年分も入れると、1件追加するたびに数MBを書き直すことになります。
そこで、支出は expenses_2026-10 のように暦月ごとのキーに分けました。1回の書き込みは数KBで済み、上限10MBに対して数万件入るので、unlimitedStorage 権限も不要です。
データのバージョン管理
将来項目を足しても既存のデータを壊さないよう、スキーマのバージョンと移行関数を最初から用意しました。
// バージョンを上げるときは、ここに「新バージョン番号: 変換関数」を追加する
const MIGRATIONS = {
// 例)v2でプロフィールに「ペットの有無」を追加する場合
// 2: (d) => { if (d.profile) d.profile.hasPet ??= null; },
};
移行の直前には全データを1世代だけ退避します。JSONバックアップからインポートしたデータにも、同じ移行処理を通します。
グラフはSVGで自作
Manifest V3では外部CDNからスクリプトを読み込めません。ライブラリを同梱する手もありますが、横棒と折れ線だけなので SVGを直接組み立てて描きました。ビルドもライブラリの更新も不要で、初級者でも保守しやすくなります。
テストの工夫:chrome.storage をモックして Node で動かす
ロジックを画面から切り離しておいたので、chrome.storage.local をメモリ上の簡単なモックに差し替えるだけで、Node.js で通しのテストができました。
const db = {};
globalThis.chrome = {
storage: {
local: {
async get(keys) { /* db から返す */ },
async set(obj) { Object.assign(db, structuredClone(obj)); },
async clear() { for (const k of Object.keys(db)) delete db[k]; },
},
},
};
const an = await import('./lib/analysis.js');
// プロフィール → アンケート → 支出 → 分析 → 月次アンケート まで通しで検証
画面の確認も、同じモックを差し込んだページをブラウザで開いて行いました。拡張機能として読み込み直す回数が減り、開発がかなり速くなりました。
ハマったところ
DOMの append(null) は "null" という文字列になる
条件によって要素を出し分けるとき、parent.append(cond ? el : null) と書くと、画面に 「null」という文字が表示されます。標準の append / replaceChildren は、null を文字列として追加するためです。
// NG:条件が偽のとき "null" と表示される
body.append(title, hasSub ? subEl : null);
// OK:配列にしてから取り除く
body.append(...[title, hasSub ? subEl : null].filter(Boolean));
矛盾する提案が同時に出る
「余ったら貯める」と答えた人に、「先取り貯金を始めましょう」と「先取り額を増やしましょう」が同時に出ていました。条件を独立に書くと起きやすいので、後者はすでに先取りしている人だけに限定しました。条件分岐のロジックでは、提案同士の関係もテストで確認するのが大事だと感じました。
おわりに
お金の動画で得た知識を「自分の場合はどうか」に置き換えるために、ヒアリングと支出の記録で助言が変わるアプリを作りました。
- 個人開発なら、AIを使わない条件分岐でも十分に「パーソナライズ感」は出せる
- Manifest V3は制約が多いけれど、権限を絞ってCSPで通信を禁止すれば、ユーザーに安心してもらえる設計にできる
- ビルドなしの素のJavaScriptでも、モジュールを分けておけばテストも保守もしやすい
というのが、作ってみての学びです。
Chromeウェブストアで近日公開予定です。家計管理系のアプリを作っている方の参考になればうれしいです。
本アプリは一般的な家計管理の工夫を提案するもので、特定の金融商品の勧誘や投資の助言を行うものではありません。


