家計簿アプリを作る #37:カテゴリ別予算設定とアラート表示
家計簿アプリ作成シリーズの第37回です。前回実装したカテゴリ管理機能を発展させて、カテゴリごとに月間予算を設定し、予算オーバーしたらアラート表示する機能を作ります。
実装方針
budgets テーブルを新設し、カテゴリ登録・編集時に予算額もあわせて登録します。収支一覧ページでは、選択中の月のカテゴリ別支出合計と予算を突き合わせて、オーバーしているカテゴリを警告表示します。
スキーマの追加
export const budgets = sqliteTable('budgets', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
categoryId: text('category_id').notNull(),
amount: int('amount').notNull(),
});
categories テーブルと同様、外部キー制約は付けず既存の設計方針に揃えています。
カテゴリと予算の結合取得
カテゴリ一覧・収支一覧の両方で、categories と budgets を leftJoin して取得します。leftJoin にすることで、予算未設定のカテゴリ(や収入カテゴリ)でも除外されずに取得できます。
const allCategories = await db.select({
id: categories.id,
name: categories.name,
type: categories.type,
amount: budgets.amount,
})
.from(categories)
.leftJoin(budgets, and(eq(categories.id, budgets.categoryId), eq(categories.userId, budgets.userId)))
.where(eq(categories.userId, locals.user!.id));
ActionData の型が割れてしまう問題
カテゴリ登録処理で、通常のバリデーションエラーとは別に、カテゴリ登録後の再取得に失敗した場合のエラーも fail() で返すようにしたところ、+page.svelte 側で型エラーが出ました。
// バリデーションエラー
return fail(400, { errors, values: { name, type, amount } });
// 再取得失敗時のエラー(❌ 型が異なる)
return fail(500, {
errors: { general: 'カテゴリの登録に失敗しました' },
values: { name, type, amount }
});
1つ目の errors は Record<string, string>、2つ目は { general: string } という別の形になっていたため、SvelteKitが生成する ActionData がこの2つの合併(union)型になり、form?.errors?.name のようなアクセスで「{ general: string } に name は存在しない」という型エラーになりました。errors の形を揃える(as Record<string, string> でキャストする)ことで解消しました。
カテゴリ別支出合計はJSで集計
月フィルタや検索条件を都度DBに問い合わせるのではなく、既存の設計方針(DBから一括取得してJS側で filter/reduce する)に合わせ、カテゴリ別支出合計もJS側で計算しました。
const categoryExpenseMap: Record<string, number> = {};
monthTransactions
.filter(t => t.type === 'expense')
.forEach(t => {
categoryExpenseMap[t.category] = (categoryExpenseMap[t.category] ?? 0) + t.amount;
});
const categoryBudgets = allCategories
.filter(c => c.type === 'expense')
.map(c => ({
name: c.name,
budget: c.amount ?? 0,
expense: categoryExpenseMap[c.name] ?? 0,
}));
$derived の中にアロー関数を書いてしまう間違い
予算オーバーのカテゴリを抽出する処理を $derived で書いたところ、配列を渡しているつもりが関数そのものが値になってしまい、型エラーになりました。
// ❌ 関数そのものが値になってしまう
const overBudgetCategories = $derived(() => {
const overBudget = [];
for (const category of data.categoryBudgets) { ... }
return overBudget;
});
$derived は式の評価結果をそのまま値として持つため、複数行の処理を書きたい場合は $derived.by(...) を使う必要がありました。
// ✅ $derived.byを使う
const overBudgetCategories = $derived.by(() => {
const overBudget = [];
for (const category of data.categoryBudgets) { ... }
return overBudget;
});
カテゴリ削除時に予算データが孤立する問題
カテゴリ削除処理で categories テーブルのみ削除し、対応する budgets レコードを削除していなかったため、孤立した予算データが残ってしまう問題がありました。budgets を先に削除してから categories を削除するよう修正しました。
await db.delete(budgets)
.where(and(eq(budgets.categoryId, categoryId), eq(budgets.userId, locals.user!.id)));
await db.delete(categories)
.where(and(eq(categories.id, categoryId), eq(categories.userId, locals.user!.id)));
なお、予算オーバーしているカテゴリが削除された後、そのカテゴリの取引データ自体は残る仕様としました(取引の category はただの文字列で保持しているため)。
サマリーCardの幅が揃わない問題
収支一覧ページの収入合計・支出合計・残高の3枚の Card が、テーブルの横幅に対して右側に余白ができてしまう問題がありました。原因はflowbite-svelteの Card コンポーネントがデフォルトで max-w-sm 相当の最大幅を持っているためでした。
<!-- ❌ Cardの最大幅制約でグリッドセルいっぱいに広がらない -->
<Card class="p-2">...</Card>
<!-- ✅ max-w-sm を打ち消して幅いっぱいに広げる -->
<Card class="p-2 max-w-none! w-full">...</Card>
まとめ
| ポイント | 内容 |
|---|---|
| カテゴリと予算の取得 | leftJoin で結合し、予算未設定でも除外されないようにする |
fail() のerrors型 |
複数の fail() で形を揃えないと ActionData の型が割れる |
| カテゴリ別集計 | 既存方針に合わせ、DBの GROUP BY ではなくJS側で filter/reduce |
$derived の複数行処理 |
$derived.by(() => {...}) を使う |
| カテゴリ削除 | 関連する budgets レコードも一緒に削除する |
Card の幅 |
デフォルトの max-w-sm を max-w-none! で打ち消す |