ハコヅミを作る #9:一番の売り、コンテナの一括チェック

前回(#8)で、チェックリストでギアを1つずつチェックできるようになりました。 今回は、ハコヅミを作ろうと思ったきっかけでもある、コンテナ単位の一括チェックです。

#1で書いたとおり、キャンプの荷物は、ギアをコンテナにまとめて入れておき、コンテナごと車に積みます。コンテナを積んだら、中のギアもまとめてチェックされてほしい。Notionではできなかったことです。


見た目

チェックリストの画面は、#8の時点で、すでにコンテナごとにまとめて並べていました。 今回は、各コンテナの見出しの左にチェックボックスを置きました。

チェックリストの画面。カーゴ 30L-1 の見出しのチェックボックスにチェックが付き、中のギアがすべてチェック済み。カーゴ 50L-1 の見出しは一部の表示になっている

実際の画面(上のほうを切り出したもの)。カーゴ 30L-1 はまとめてチェック済み、カーゴ 50L-1 は「一部」

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


コンテナのチェック状態は、保存しない

一番考えたのは、コンテナのチェック状態をどこに持つかです。

コンテナにも「チェック済み」の列を用意する方法もあります。ただそうすると、ギアを1つずつチェックした場合と、コンテナでまとめてチェックした場合とで、状態が食い違うおそれがあります。 たとえば、コンテナにはチェックが付いているのに、中のギアが1つだけ外れている、といった状態です。

そこで、コンテナのチェック状態は保存せず、中のギアのチェック状態から毎回決めることにしました。テーブルは1つも変えていません。

// none:対象のギアがない(すべて今回不要)ので押せない
export type ContainerCheckState = 'checked' | 'mixed' | 'unchecked' | 'none';

export function containerCheckState(items: { checked: boolean; notNeeded: boolean }[]) {
	const { checked, target } = campProgress(items); // 今回不要を除いて数える
	if (target === 0) return 'none';
	if (checked === target) return 'checked';
	return checked === 0 ? 'unchecked' : 'mixed';
}
中のギア(今回不要を除く) コンテナ
すべてチェック済み チェック
一部だけチェック済み 「一部」
どれもチェックされていない 未チェック
対象がない(すべて今回不要) 押せない

こうしておけば、ギアを1つずつ全部チェックしたときも、コンテナは自然に「チェック」になります。状態の持ち主が1か所なので、食い違いようがありません。


押したときの決めごと

コンテナのチェックボックスを押したときの動きは、Issueの段階で細かく決めました。

  • チェックを付けると、そのコンテナの今回不要でないギアをすべてチェックする
  • チェックを外すと、すべて外す
  • 今回不要のギアは変えない。積まない前提のギアなので、コンテナごと積んでもチェックは付けない
  • 「一部」の状態で押したら、全部チェックにする。「コンテナを積んだ」という操作なので、チェックする側に倒した

サーバー側では、そのキャンプの項目のうち、そのコンテナに入っているギアで、今回不要でないものを、まとめて更新します。

await db
	.update(campItems)
	.set({ checked: input.value })
	.where(
		and(
			eq(campItems.campId, campId),
			eq(campItems.notNeeded, false),
			inArray(campItems.gearId, gearsInContainer) // そのコンテナに入っている自分のギア
		)
	);

「一部」の表示:indeterminate

チェックボックスには、チェックと未チェックの間の「一部」の表示があります。HTMLの indeterminate です。 ただし、これはHTMLの属性ではなく、DOMのプロパティなので、<input indeterminate> のように書いても効きません。JavaScriptで要素に直接設定する必要があります。

Svelte 5 では、{@attach} を使って要素に直接設定しました。

<script lang="ts">
	// 「一部」の状態は HTML の属性ではなく DOM のプロパティ(indeterminate)で表す
	function indeterminate(state: ContainerCheckState) {
		return (node: HTMLInputElement) => {
			node.indeterminate = state === 'mixed';
		};
	}
</script>

<input
	type="checkbox"
	checked={group.checkState === 'checked'}
	disabled={group.checkState === 'none'}
	{@attach indeterminate(group.checkState)}
/>

{@attach} は、状態が変わると自動で設定し直してくれるので、ギアを1つチェックするたびに「一部」の表示も追いかけて変わります。

E2Eテストでも、この「一部」の状態を、要素の indeterminate を読んで確かめています。


戻し忘れに気づく「中身をすべて見る」

一括チェックには、弱点があります。「コンテナの中身が正しい」ことが前提だという点です。 前回のキャンプで使ったランタンを、コンテナに戻し忘れていたら、一括チェックしても気づけません。

そこで、コンテナの見出しの下に「中身をすべて見る」を置きました。開くと、そのコンテナに入っている自分のギアが、すべて出ます。

  • チェックリストにないギア(プランに入っていないもの)も出す
  • それには「(このチェックリストにない)」、廃棄済なら「・廃棄済」と添える
カーゴ 30L-1 の「中身をすべて見る」を開いた状態。コンテナに入っているギアが並び、一部に「(このチェックリストにない)」と添えられている

「中身をすべて見る」を開いたところ。プランに入っていない食器などには注記が付く

積み込みの前にさっと開いて、「入っているはずのものがあるか」を目で確かめる使い方を想定しています。別の画面に移らず、その場で開閉できる形にしました。


テストの思い込み

この機能のE2Eで、1つだけ落ちたテストがありました。「中身をすべて見る」のギアの並び順を、予想で決め打ちしていたためです。

中身は製品名の日本語の並び順で並べていますが、「古いランタン」「予備のランタン」「ランタン」の並びは、予想と違っていました。漢字とカタカナの前後は、日本語の並びの規則次第です。

このテストで確かめたかったのは「全部出ること」と「チェックリストにないものが分かること」で、並び順ではありません。並び順に頼らない確かめ方に直しました。 テストで何を確かめたいのかをはっきりさせておくと、こうした思い込みに振り回されずに済みます。


おわりに

  • コンテナのチェック状態は保存せず、中のギアから決める(食い違いが起きない)
  • 「一部」は indeterminate。属性ではなくプロパティなので、{@attach} で設定する
  • 一括チェックの前提(中身が正しい)を、「中身をすべて見る」で補う

これで、ハコヅミを作ろうと思ったきっかけの機能ができました。MVPの機能は、これですべてそろいです。次回は、MVPができるまでを振り返ります。

← トップページに戻る