家計簿アプリを作る #55:月間の総予算設定と進捗表示

家計簿アプリ作成シリーズの第55回です。これまでカテゴリ別の予算は設定できましたが、「今月全体でいくらまで使っていいか」という総額の予算はありませんでした。月間の総予算を設定し、収支一覧ページで進捗を表示できるようにします。

実装方針

monthlyBudgets テーブルをユーザー1人につき1レコードという設計で新設し、設定ページ(既存の /settings をタブ切り替えで拡張)で登録・編集できるようにします。収支一覧ページでは、当月の支出合計 ÷ 総予算額の進捗をプログレスバーで表示します。

Drizzleでupsertする

ユーザー1人につき1レコードという制約を持たせるには、userId に一意制約(unique)を付け、onConflictDoUpdate でupsertします。

export const monthlyBudgets = sqliteTable('monthly_budgets', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId: text('user_id').notNull().unique(),
  amount: int('amount').notNull(),
});
await db.insert(monthlyBudgets)
  .values({ userId: locals.user!.id, amount: monthlyBudget })
  .onConflictDoUpdate({
    target: monthlyBudgets.userId,
    set: { amount: monthlyBudget },
  });

target に一意制約のあるカラムを指定し、競合した場合の更新内容を set に書くことで、「無ければinsert、あればupdate」を1回のクエリで実現できます。

設定ページをタブ切り替えで拡張

既存のパスワード変更ページ(/settings)に、flowbite-svelteの Tabs/TabItem を使って「月間の総予算設定」タブを追加しました。1ページの中で複数の独立したフォーム(changePassword アクションと setMonthlyBudget アクション)を切り替えて表示する形です。

ハマりポイント:予算未設定時の0除算バグ

サーバー側では、予算が未設定の場合 monthlyBudget?.amount ?? 0 として 0 を返すようにしていました。一方クライアント側では、消化率の計算で null/undefined しかチェックしていませんでした。

// ❌ 0はチェックされない
const monthlyBudgetConsumptionRate = $derived.by(() => {
  if (data.monthlyBudget === null || data.monthlyBudget === undefined) {
    return 0;
  }
  return Math.min((data.totalExpense / data.monthlyBudget) * 100, 100);
});

予算未設定(monthlyBudget === 0)のユーザーがページを開くと、支出 / 0Infinity(支出も0円なら NaN)になり、Math.min(Infinity, 100) の結果、消化率100%・赤色のプログレスバーが表示されてしまうバグがありました。

// ✅ 0もまとめてチェックする
const monthlyBudgetConsumptionRate = $derived.by(() => {
  if (!data.monthlyBudget) {
    return 0;
  }
  return Math.min((data.totalExpense / data.monthlyBudget) * 100, 100);
});

あわせて、予算が未設定の場合はプログレスバー自体を表示せず、「予算を設定してください」という案内に差し替えるようにしました。

{#if data.monthlyBudget > 0}
  <Progressbar progress={monthlyBudgetConsumptionRate} labelInside color={monthlyBudgetConsumptionColor} />
{:else}
  <p class="text-sm text-gray-500">月間の予算が設定されていません。<a href="/settings">設定</a>から設定してください。</p>
{/if}

まとめ

ポイント 内容
upsert unique()制約 + onConflictDoUpdate で「無ければinsert、あればupdate」を1クエリで実現する
設定ページの拡張 Tabs/TabItem で複数の独立したフォームを1ページにまとめる
0とnull/undefinedの違い サーバーが「未設定」を0で表現する場合、クライアント側も0を明示的にチェックする
0除算対策 予算が0の状態を先に弾き、割り算自体を実行させない
← トップページに戻る