ハコヅミを作る #8:プランとチェックリスト、D1の「1文に値は100個まで」
前回(#7)で、実際のギアとコンテナが入りました。 今回は、ハコヅミの中心になる2つの機能です。
- プラン:ソロ・宿泊、ファミリー・日帰りなど、キャンプの種類ごとの持ち物の組み合わせ
- チェックリスト:プランを選んで作る、キャンプ1回分のチェックリスト
プラン:持っていくギアを選ぶ
プランの画面では、名前を入れて、持っていくギアをチェックボックスで選びます。 ギアは「所有」しているものを、カテゴリごとに並べ、どのコンテナに入っているかも添えました。
![プランの編集画面。名前の入力欄の下に、カテゴリごとのギアのチェックボックスが並び、ギアによっては[カーゴ 30L-1]のようにコンテナ名が付いている](/posts/hakozumi-08-plans-checklist/images/plan-edit.png)
プランの編集画面(上のほうを切り出したもの)。「所有」のギアをカテゴリごとに並べ、持っていくものにチェックを付ける

プランの一覧(ギアの数付き)
画面は、この記事の時点のコードを手元で動かして撮ったものです。データは#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で使っていた「今回不要」も、チェックリストの項目ごとに付けられるようにしました。 進み具合は「チェック済 / 対象」で表示し、対象は「全体 − 今回不要」です。今回不要のギアは薄く表示して、チェックできないようにしています。

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

チェックリストの一覧(日付の新しい順)
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つずつチェックできるようになりました。次回は、いよいよハコヅミの一番の売り、コンテナ単位の一括チェックです。