家計簿アプリを作る #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 テーブルと同様、外部キー制約は付けず既存の設計方針に揃えています。

カテゴリと予算の結合取得

カテゴリ一覧・収支一覧の両方で、categoriesbudgetsleftJoin して取得します。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つ目の errorsRecord<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-smmax-w-none! で打ち消す
← トップページに戻る