家計簿アプリを作る #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)のユーザーがページを開くと、支出 / 0 が Infinity(支出も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の状態を先に弾き、割り算自体を実行させない |