家計簿アプリを作る #36:カテゴリ管理機能
家計簿アプリ作成シリーズの第36回です。これまで収入・支出のカテゴリは categoryMap としてハードコードしていましたが、ユーザーが自由にカテゴリを追加・削除できる機能を実装します。
実装方針
categories テーブルを新設し、収支の登録・編集フォームのカテゴリ選択をハードコードからDB取得に切り替えます。
スキーマの追加
export const categories = sqliteTable('categories', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
name: text('name').notNull(),
type: text('type', { enum: ['income', 'expense'] }).notNull(),
});
userId に外部キー制約(references)を付けるかどうかを検討しましたが、既存の transactions.userId が制約なしで統一されているため、一貫性を優先して制約なしにしました。
カテゴリ一覧・登録・編集・削除
既存の収支一覧・登録・編集ページと同じパターン(+page.server.ts でCRUD処理、+page.svelte でflowbite-svelteのTable/Form)で /categories 配下に実装しました。
登録時、FormData から取得した type の値をそのまま db.insert に渡すと型エラーになりました。
// ❌ stringのままだと型エラー
const type = String(formData.get('type') ?? '');
// ✅ リテラル型にキャストする
const type = String(formData.get('type') ?? '') as 'income' | 'expense';
type カラムが text('type', { enum: ['income', 'expense'] }) というリテラル型のenumで定義されているため、FormData から取得したただの string は直接渡せません。
新しいルート追加時の型エラー
新しく /categories ルートを追加した直後、+page.svelte で data.categories が Property 'categories' does not exist on type '{}'. という型エラーになりました。原因はSvelteKitが自動生成する $types がまだ新しいルートに追従していなかったことで、以下のコマンドで解消しました。
npx svelte-kit sync
新しいルートを追加した直後にこの手のエラーが出たら、まず svelte-kit sync を試すとよさそうです。
収支フォームのカテゴリ選択をDB連携に変更
transactions/new と transactions/[id]/edit のカテゴリ Select を、ハードコードの categoryMap からDB取得のカテゴリ一覧を使うように変更しました。
+page.server.ts の load でカテゴリ一覧を取得し、{ income: string[], expense: string[] } の形に整形して返します。
const allCategories = await db.select().from(categories).where(eq(categories.userId, locals.user!.id));
const categoryMap: Record<string, string[]> = {
income: allCategories.filter(c => c.type === 'income').map(c => c.name),
expense: allCategories.filter(c => c.type === 'expense').map(c => c.name)
};
ハマりポイント:data から作った変数を $state の初期値に使うとワーニングが出る
let { data, form } = $props();
const categoryMap = data.categoryMap;
let selectedCategory = $state(categoryMap["income"][0]);
このように書くと、categoryMap 自体は $state ではないものの、その後 $effect や $derived で使われることで、コンパイラが「data がstate初期化に関わっている」と判定し、以下のワーニングが出ました。
This reference only captures the initial value of `data`.
Did you mean to reference it inside a closure instead?
categoryMap の定義自体を untrack で囲むことで解消しました。
// ✅ dataへの参照をクロージャの中に閉じ込める
const categoryMap = untrack(() => data.categoryMap);
編集ページでは、既存の取引データが持つカテゴリが現在のカテゴリ一覧に存在しない(カテゴリが削除された後など)場合に一覧ページへリダイレクトするチェックも追加しました。
if (!transaction || !categoryMap[transaction.type].includes(transaction.category)) {
redirect(303, '/');
}
仕様として割り切ったこと
カテゴリ削除時に、そのカテゴリが既存の取引データで使われているかどうかはチェックしていません。取引データの category は単なる文字列として保持しているため、使用中のカテゴリを削除しても取引データ自体は残ります。ただしその後そのカテゴリの取引を編集しようとすると、上記のリダイレクトチェックに引っかかり編集できなくなります。今回はこれを仕様として許容することにしました。
まとめ
| ポイント | 内容 |
|---|---|
| 外部キー制約 | 既存テーブルとの一貫性を優先し、あえて付けない |
| enum型へのキャスト | FormData から取得した値は as 'income' | 'expense' でキャストする |
| ルート追加後の型エラー | npx svelte-kit sync で $types を再生成する |
$state 初期化時の data 参照 |
派生変数ごと untrack で囲む |
| カテゴリ削除の仕様 | 使用中でも削除可能とし、参照エラーは編集時のリダイレクトで吸収する |