ハコヅミを作る #16:ガス缶や食材などの消耗品を、ギアとは別に管理する
前回(#15)でキャンプの情報がそろったので、今回は持ち物の側を広げます。ガス缶・薪・調味料・食材など、キャンプのたびに減ったり、用意し直したりするもの(消耗品)です。
Notion の「今後の機能強化」の「消耗品の管理」にあたり、ステップ4の「消耗品の購入フォロー」(残りがなくなったら買い足しを知らせる)の土台にもなります。
3つの Issue に分けた
一度に全部やると大きくなるので、3つに分けて、1つずつ受入確認をしながら進めました。
| Issue | 内容 |
|---|---|
| #75 | 消耗品の登録・編集・削除(一覧、種類、残り、コンテナ) |
| #77 | 数量と、コンテナの中身の画面に消耗品を出す |
| #78 | プランと持ち物チェックに、消耗品を入れる |
#75 で「対象外」に書いたものを、#77・#78 で拾っていく形です。最初から対象外を書いておくと、「何をまだやっていないか」が Issue に残るので、次の Issue の下書きがそのまま作れます。
ギアとは別のテーブルにした
消耗品を gears の1行として、「種類:消耗品」のように扱う案もありました。それでも、別のテーブル(supplies)にしています。
- 製品で表すものではない:ギアはメーカーと製品名で表すが、「薪」「卵」にメーカーはない
- 大事なのは残りがあるかどうか:ギアには、おすすめ度や画像、「欲しい」「廃棄済」の状態がある。消耗品に要るのは、残りと数量
- ギアの画面や処理を変えずに済む:同じテーブルにすると、ギアの一覧・プラン・持ち物チェックのあちこちに「消耗品なら」という分かれ道が入る
一方で、コンテナとの紐づけはギアと同じ形にしました。ガス缶をカーゴに入れっぱなしにしておく、という使い方はギアと変わらないからです。
「残りを管理する」と「毎回用意する」
消耗品には、性質の違う2種類があります。
| 種類 | 例 | 残り |
|---|---|---|
残りを管理する(stock) |
ガス缶、薪、調味料、ゴミ袋 | 「残りあり」「残りなし」を持つ |
毎回用意する(every_time) |
食パン、卵などの食料品 | 持たない(null) |
ガス缶は、使い切るまで何回か持っていきます。食料品は、キャンプのたびに買うので、残りを気にしません。この違いを kind の列で表し、残り(in_stock)は stock のときだけ使います。

消耗品の一覧。「残りなし」は目立つ色にした
画面は、この記事の時点のコードを手元で動かして撮ったものです。ギアとコンテナは#7で Notion から取り込んだ実際のもので、消耗品は記事用に登録したものです。
「残りなし」は、削除やエラーと同じ色(danger)にしました。一覧を開いたときに、買い足すものがすぐ目に入るようにするためです。
数量(#77)
#75 では数量を持たせていませんでしたが、使ってみると「ガス缶は2本」「卵は6個」のように、持っていく数を残したくなります。
supplies に quantity(1〜99、既定値 1)を足しました。一覧や持ち物チェックでは、2以上のときだけ「×2」と出します。1のときに「×1」と出すと、ほとんどの行に付いてうるさいためです。

消耗品の編集画面。種類が「毎回用意する」のときは、残りの欄は出ない
あわせて、コンテナの編集画面の「中身」にも消耗品を出しました。コンテナに何が入っているかを見るときに、ギアしか出ないのは不便だったためです。
プランと持ち物チェックに入れる(#78)
ここが本番です。消耗品をプランに入れ、キャンプの持ち物チェックで、ギアと一緒にチェックできるようにしました。

プランの編集画面に足した「持っていく消耗品」
項目のテーブルも、ギアとは別にした
持ち物チェックの項目は、ギアなら camp_items(キャンプ × ギア)です。ここに消耗品も入れられれば楽ですが、camp_items はギアへの外部キーを持っています。消耗品も入れるには、外部キーを外したり列を足したりするテーブルの作り直しが要ります(#5で苦労したものです)。
そこで、消耗品用に camp_supply_items を新しく作りました。プランとの紐づけも、同じ理由で plan_supplies を別に作っています。
plan_gears (plan_id, gear_id)
plan_supplies (plan_id, supply_id) ← 新しく作った
camp_items (camp_id, gear_id, checked, not_needed)
camp_supply_items (camp_id, supply_id, checked, not_needed) ← 新しく作った
画面では、ひとつにまとめて扱う
テーブルは分かれていても、持ち物チェックの画面では、ギアと消耗品を同じように並べたいところです。コンテナごとにまとめる、進み具合に数える、コンテナの一括チェックで両方をチェックする、などです。
そこで、2種類の項目を1つの形にそろえる関数(toCampViewItems())を作り、そのあとの処理(コンテナごとのまとめ、進み具合、コンテナのチェック状態)は、これまでの関数をそのまま使えるようにしました。項目には gear:〜・supply:〜 のキーを付けて、ギアと消耗品で ID が重なっても区別できるようにしています。
return [
...gearItems.map((item) => ({
key: `gear:${item.gearId}`,
kind: 'gear' as const,
// ...
})),
...supplyItems.map((item) => ({
key: `supply:${item.supplyId}`,
kind: 'supply' as const,
// ...
}))
];

持ち物チェック。カーゴの中のガス缶(消耗品)も、ギアと一緒に並ぶ
残りがない消耗品には、持ち物チェックでも「残りなし」と出します。積み込みの途中で、「OD缶 110 は残りがないから、買ってこないと」と気づけるようにするためです。
「中身をすべて見る」にも、消耗品を出しています。

「中身をすべて見る」。ギアと消耗品をまとめて名前順に出す
CI でだけ落ちた E2E
#78 の PR では、CI の E2E が1件だけ落ちました。手元では何度流しても通るテストです。
落ちたのは、「ギアをチェックしたら、コンテナのチェックボックスが『一部』の表示になる」ことを確かめるテストでした。チェックの送信が終わるのは待っていましたが、そのあと画面が描き直されるのは待たずに、「一部」かどうかを読んでいました。手元のマシンは速いので描き直しが間に合い、CI では間に合わなかった、というわけです。
// 「一部」の表示になるまで、何度か読み直して待つ
await expect
.poll(() => containerBox.evaluate((el) => el instanceof HTMLInputElement && el.indeterminate))
.toBe(true);
「一部」の表示(indeterminate)は HTML の属性ではなく JavaScript のプロパティなので、Playwright の toBeChecked() のような「待ってくれる」確かめ方が使えません。expect.poll で、値が変わるまで読み直すようにしました。#6で書いた「待つべきものを待っていない」の、また1つの例です。
直したあとの CI は、今度はテストではなく、15分の時間の上限で止められました。E2E が増えて、CI 全体が15分近くかかるようになっていたためです。この話は次回にまとめます。
おわりに
- 消耗品は、性質が違うのでギアとは別のテーブルに。コンテナとの紐づけだけはギアと同じ形
- 「残りを管理する」と「毎回用意する」の2種類。残りなしは目立たせる
- 持ち物チェックの項目も別のテーブルにして、テーブルの作り直しを避けた。画面では1つの形にそろえて、これまでの処理を使い回す
- 「一部」の表示は待ってくれないので、
expect.pollで待つ
3つの Issue とも、本番にリリースしました。残りがなくなった消耗品を「次のキャンプまでに買い足す」ように知らせる機能(購入フォロー)も、これで作れる下地ができました。