ハコヅミを作る #8:プランとチェックリスト、D1の「1文に値は100個まで」

前回(#7)で、実際のギアとコンテナが入りました。 今回は、ハコヅミの中心になる2つの機能です。

  • プラン:ソロ・宿泊、ファミリー・日帰りなど、キャンプの種類ごとの持ち物の組み合わせ
  • チェックリスト:プランを選んで作る、キャンプ1回分のチェックリスト

プラン:持っていくギアを選ぶ

プランの画面では、名前を入れて、持っていくギアをチェックボックスで選びます。 ギアは「所有」しているものを、カテゴリごとに並べ、どのコンテナに入っているかも添えました。

プランの編集画面。名前の入力欄の下に、カテゴリごとのギアのチェックボックスが並び、ギアによっては[カーゴ 30L-1]のようにコンテナ名が付いている

プランの編集画面(上のほうを切り出したもの)。「所有」のギアをカテゴリごとに並べ、持っていくものにチェックを付ける

プランの一覧。ソロ・宿泊、デュオ・宿泊、ファミリー・日帰りと、それぞれのギアの数が並んでいる

プランの一覧(ギアの数付き)

画面は、この記事の時点のコードを手元で動かして撮ったものです。データは#7でNotionから取り込んだ、実際のギアを入れています。

テーブルは、プラン(plans)と、プランとギアの多対多(plan_gears)です。

50件以上のギアを選ぶと保存できない

実装しながら気になったのが、D1の制約です。 D1は、1つのSQL文に渡せる値が100個までです。plan_gears は1行に値が2つ(プランとギア)なので、50件を超えるギアを一度に追加しようとすると、この上限を超えます。

取り込んだギアは56件あるので、「全部入りのプラン」を作ればすぐにぶつかります。そこで、追加を40行ずつに分けて、1つのバッチで送るようにしました。

// D1 は1つの SQL 文に渡せる値が100個まで。plan_gears は1行に2つなので、40行ずつ追加する
const PLAN_GEARS_PER_INSERT = 40;

await db.batch([
	db.insert(plans).values({ id, userId, name }),
	...chunk(gearIds, PLAN_GEARS_PER_INSERT).map((ids) =>
		db.insert(planGears).values(ids.map((gearId) => ({ planId: id, gearId })))
	)
]);

これを確かめるために、55件のギアを選んで保存するE2Eテストを足しました。 さらに、わざと分けるのをやめて(1000件ずつにして)テストを流し、次のエラーで落ちることも確かめています。

D1_ERROR: too many SQL variables at offset 456: SQLITE_ERROR

「テストが通る」だけでなく、「直す前の状態ならテストが落ちる」ことまで見ておくと、そのテストが本当に守りになっているかが分かります。

見えないものを消さない

もう1つ気をつけたのが、プランを編集して保存するときです。

画面に出るギアは「所有」のものだけです。一方で、プランに入れたあとでギアを「廃棄済」にすることもあります。 編集で「選ばれたギアで紐づけを全部入れ替える」と、画面に出ていない廃棄済のギアの紐づけまで消えてしまいます。

そこで、入れ替えるのは「所有」のギアの紐づけだけにしました。画面に出ていないものは、画面の操作では変えない、という考え方です。


チェックリスト:キャンプ1回分を作る

チェックリストは、プランと日付を選んで作ります。 テーブルは、キャンプ1回分(camps)と、そのキャンプでの各ギアの状態(camp_items)です。#1で書いた「ギアと、毎回のチェックを分ける」設計が、ここで形になりました。

チェックリストを作る画面。プラン・日付・メモの入力欄と「作る」ボタンがある

チェックリストを作る画面。プラン・日付(初期値は日本時間の今日)・メモを入れる

作った時点のプランで固定する

チェックリストの項目は、作った時点で、プランに含まれる「所有」のギアから作ります。 あとでプランを変えても、作ったチェックリストは変わりません。

キャンプの記録として残していくことを考えると、「そのとき何を持っていったか」が後から変わってしまうのは困るためです。

チェックは、押した時点で保存する

チェックリストを使うのは、車に荷物を積んでいるときです。いちいち「保存」を押すのは手間なので、チェックボックスを押した時点で、その項目だけを保存するようにしました。

作りは単純で、項目ごとに小さなフォームを置き、チェックボックスが変わったらそのフォームを送ります。

<form method="POST" action="?/toggle" use:enhance>
	<input type="hidden" name="gearId" value={item.gearId} />
	<input type="hidden" name="field" value="checked" />
	<input type="hidden" name="value" value={String(!item.checked)} />
	<label>
		<input type="checkbox" checked={item.checked} onchange={submitOnChange} />
		{item.productName}
	</label>
</form>

SvelteKitの use:enhance を使っているので、送ったあとはページのデータが読み直され、進み具合の表示も自動で更新されます。

進み具合と「今回不要」

Notionで使っていた「今回不要」も、チェックリストの項目ごとに付けられるようにしました。 進み具合は「チェック済 / 対象」で表示し、対象は「全体 − 今回不要」です。今回不要のギアは薄く表示して、チェックできないようにしています。

チェックリストの画面。日付とプラン名の見出し、進み具合「5 / 35」、コンテナごとのギアの一覧。いくつかにチェックが付き、アルミポールは今回不要で薄く表示されている

チェックリストの画面(上のほうを切り出したもの)。コンテナごとに並び、今回不要のギアは薄く表示される

チェックリストの一覧。日付・プラン名・進み具合が新しい順に並んでいる

チェックリストの一覧(日付の新しい順)

export function campProgress(items: { checked: boolean; notNeeded: boolean }[]) {
	const targets = items.filter((item) => !item.notNeeded);
	return { checked: targets.filter((item) => item.checked).length, target: targets.length };
}

削除したときの扱いを、ここで決めた

#3でギアの削除を作ったとき、「ギアを参照するテーブルができたら、削除したときの扱いを決め直す」と保留にしていました。チェックリストで、ついにその時が来ました。

  • ギアを削除すると、過去のキャンプの項目からも消える
    • 手放したギアは削除せず「廃棄済」にする使い方なので、削除は「間違えて登録したものを消す」ときだけ、と割り切りました
  • プランを削除しても、チェックリストは残る
    • プラン名の欄は空になり、画面では「(削除されたプラン)」と表示します

どちらも外部キー(on delete cascade / set null)で実現していますが、#5と同じく、アプリ側でも同じバッチの中で明示的に消す(または空にする)ようにしています。


ちょっとした話:スキルは自分からしか呼べない

チェックリストのIssueを作るとき、Claude Codeに「/issue で作りますか?」と聞かれて「はい」と答えました。 ところが、/issue のスキルは#4で「人が呼んだときだけ動く」設定にしています。Claude Codeからは呼び出せないので、スキルと同じ手順で下書きを作ってくれました。

GitHubにIssueやPRを作る操作は、自分の手で始める。この設定は、意図どおりに効いています。


おわりに

  • D1は1つのSQL文に値が100個まで。たくさんの行は小分けにして追加する
  • 「直す前なら落ちる」ことまで確かめて、テストが守りになっているかを見る
  • チェックリストは作った時点で固定し、チェックは押した時点で保存する

これで、プランを選んでチェックリストを作り、ギアを1つずつチェックできるようになりました。次回は、いよいよハコヅミの一番の売り、コンテナ単位の一括チェックです。

← トップページに戻る