家計簿アプリを作る #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.sveltedata.categoriesProperty 'categories' does not exist on type '{}'. という型エラーになりました。原因はSvelteKitが自動生成する $types がまだ新しいルートに追従していなかったことで、以下のコマンドで解消しました。

npx svelte-kit sync

新しいルートを追加した直後にこの手のエラーが出たら、まず svelte-kit sync を試すとよさそうです。

収支フォームのカテゴリ選択をDB連携に変更

transactions/newtransactions/[id]/edit のカテゴリ Select を、ハードコードの categoryMap からDB取得のカテゴリ一覧を使うように変更しました。

+page.server.tsload でカテゴリ一覧を取得し、{ 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 で囲む
カテゴリ削除の仕様 使用中でも削除可能とし、参照エラーは編集時のリダイレクトで吸収する
← トップページに戻る