わが家の脱・料理属人化プロジェクト、基本機能編の 5 回目です。
前回は、表記ゆれをなくしてレシピを検索する仕組みを紹介しました。
今回は、買い物リストの仕組みを紹介します。
アプリの中で、いちばん設計に悩んだ機能です。
- 導入編はこちら → わが家の料理属人化を解消したくて、家族専用のレシピアプリを作った(導入編)
- アプリ全体像はこちら → 家族のレシピアプリの全体像 - Firebase と Cloudflare Workers でコスト 0 で運用(基本機能編 1)
- 前回はこちら → 家族のレシピアプリ - 玉ねぎ/たまねぎ/タマネギ/玉葱を Trie の表記ゆれ辞書で同じ語にする(基本機能編 4)
困っていたこと
導入編で書いたとおり、買うもののメモはメッセージで送っていました。
追加や削除の連絡が買い物の途中に飛んできたり、口頭で言われただけだったりして、買い忘れにつながっていました。
そこで、アプリでは買い物リストを家族で 1 つ共有することにしました。
すると、今度は次のような場面が起こります。
- 妻が台所で、冷蔵庫を見ながら「牛乳」を足す
- 同じころ、私はスーパーで「卵」を足し、「にんじん」にチェックを付ける
- スーパーの奥は電波が弱く、私の端末はオフライン
このとき、どちらかの変更が消えてはいけません。
オフラインだった端末が後から同期した場合も同じです。
リストを 1 ドキュメントにすると、変更が消える
最初に思いつくのは、買い物リストを 1 つのドキュメントにして、項目を配列で持つ形です。
// やらなかった形
{ items: [{ name: '牛乳', checked: false }, { name: '卵', checked: true }] }
ただ、配列の中の項目を 1 つ書き換えるだけでも、配列はまるごと置き換わります。
そのため、オフラインだった端末が後から書き込むと、その間に家族が足した項目が消えてしまいます。
arrayUnion を使えば項目を足すことはできますが、チェックや分量の書き換えまでは表せません。
品名ごとに 1 ドキュメントにする形も考えましたが、こちらも「同じ品名を同時に足す」とぶつかります。
追加ごとに 1 ドキュメント、まとめは表示のとき
そこで、次のようにしました。
- 追加: 1 回の追加につき 1 ドキュメントを足す。追加では、既存のドキュメントを書き換えない
- 表示: 同じ品名・同じチェックの状態のドキュメントを、表示するときに 1 行にまとめる
追加は追記だけにして、集計は読むときに行います。
帳簿で、間違えても消さずに行を足していくのと同じ考え方です。
追記だけなら、同時に足しても衝突しません。
新しいドキュメントが 2 つ増えるだけで済みます。
Security Rules では、次のように決めています。
/**
* 追加ごとに 1 ドキュメント。同じ品名のまとめは表示するときに行う。
* addedAt はオフラインで追加した項目も、サーバーに届いた時刻になる
*/
match /shoppingItems/{itemId} {
allow read: if isMember(hid);
allow create: if isMember(hid)
&& request.resource.data.addedBy == request.auth.uid
&& request.resource.data.addedAt == request.time;
allow update: if isMember(hid)
&& unchanged('addedAt')
&& unchanged('addedBy')
&& unchanged('addedByName');
allow delete: if isMember(hid);
}
確かめているのは次の 3 つだけです。
- 足した人(
addedBy)は本人でなければならない - 足した日時(
addedAt)はサーバーの時刻でなければならない - チェックなどを書き換えるときも、足した人と日時は変えられない
表示のときにまとめる
まとめるキーは、「チェックの状態」と「品名を正規化した形」です。
品名の正規化には、前回の表記ゆれ辞書をそのまま使います。
そのため、妻の「玉ねぎ」と私の「タマネギ」は 1 行にまとまります。
/** 品名が同じか(検索と同じ正規化。空白を無視し、表記ゆれ辞書の代表の語にした形)とチェックの状態で分けるキー */
function groupKey(item: ShoppingItem): string {
return `${item.checked ? '1' : '0'}\u0000${toSearchKey(item.name).canonical}`;
}
/**
* ドキュメントを表示する行にまとめる。
* 未チェックの行を並び順の値の順(並べ替えていなければ最も早い追加の順)に並べ、その下にチェック済みの行を同じ規則で並べる。
* チェック済みの行も並び順の値を使うため、チェックを外すと元の位置に戻る
*/
export function groupShoppingItems(items: readonly ShoppingItem[], recipeTitle: RecipeTitleLookup): ShoppingRow[] {
const groups = new Map<string, ShoppingItem[]>();
for (const item of [...items].sort(compareItems)) {
const key = groupKey(item);
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
const rows = [...groups.values()].map((group) => toRow(group, recipeTitle)).sort(compareRows);
return [...rows.filter((row) => !row.checked), ...rows.filter((row) => row.checked)];
}
[...items].sort() と、コピーしてから並べ替えているのは、React Native の JS エンジン(Hermes)に toSorted などの ES2023 の配列メソッドがないためです。
まとめた行の分量は、追加元のレシピごとに並べます。
玉ねぎ 1個(肉じゃが)/ 2個(カレー)
工夫したのは次の 2 点です。
- 分量は足し算しない(「玉ねぎ 3 個」にしない)
→ 「1個」「1/2個」「少々」のように、単位がそろうとは限らないため - 分量の後ろに追加元のレシピ名を付ける
→ 導入編で書いた「この材料、何のために買うんだっけ?」が、店の中でも分かるようにするため
チェック済みの行とはまとめません。
買ったあとにまた足した「牛乳」は、新しい未チェックの行になります。
並べ替えは、動かした行に「間の値」を書く
買い物リストは、売り場の順に並べ替えたくなります。
そこで、長押しとドラッグで並べ替えられるようにしました。
並べ替えでも、書き込みはできるだけ減らしたいところです。
書き込むドキュメントが少ないほど、家族の同時の操作ともぶつかりにくくなります。
そのため、動かした行のドキュメントにだけ、前後の行の並び順の値の「間の値」を書くようにしました。
/** 動かした行に入れる並び順の値。前後の行の間の値が取れなければ undefined */
function orderBetween(before: ShoppingRow | undefined, after: ShoppingRow | undefined): number | undefined {
if (!before) {
return after ? after.order - 1 : undefined;
}
if (!after) {
return before.order + 1;
}
const middle = (before.order + after.order) / 2;
return middle > before.order && middle < after.order ? middle : undefined;
}
並び順の値は、並べ替えるまでは追加した時刻(ミリ秒)です。
行を動かすと、その行に前後の行の真ん中の値が入ります(先頭なら次の行の値 − 1、末尾なら前の行の値 + 1)。
ただし、間の値が取れないことがあります。
- レシピから材料をまとめて追加したとき
→ 追加した時刻が同じになり、前後の値に差がないため - 同じ所で何度も並べ替えたとき
→ 倍精度の浮動小数点数で間を半分ずつ詰めていくと、いつか真ん中の値が取れなくなるため
そのときは、未チェックの行すべてを 0, 1, 2… に振り直します。
この値は追加した時刻(ミリ秒)よりずっと小さいので、後から追加した項目は自然に一番下に来ます。
書き込みは「操作の計画」を返す関数にしておき、画面と Firestore から切り離してテストしています。
export type ShoppingWrite =
| { type: 'update'; id: string; data: Partial<Pick<ShoppingItem, 'name' | 'amount' | 'recipeId' | 'checked' | 'order'>> }
| { type: 'delete'; id: string };
export interface ShoppingWritePlan {
writes: ShoppingWrite[];
/**
* 1 つのバッチで書くか。
* 削除とまとめた行の分量の編集はバッチで書く。チェック・品名の変更は、他の人が削除したドキュメントがあっても
* 残りが捨てられないよう、ドキュメントごとに書く
*/
atomic: boolean;
}
まとめた行にチェックを付けると、裏では複数のドキュメントを書き換えます。
これをバッチで書くと、そのうち 1 つを家族が先に消していたときに、バッチ全体が失敗します。
チェックは「全部か無か」である必要がないので、ドキュメントごとに書いています。
指を離した後のがたつきを止める
並べ替えのリストで、指を離した直後に行がガタッと動くことがありました。
原因は、購読の通知のたびに、中身の変わらない行まで新しいオブジェクトに作り直していたことです。
その結果、すべての行が描き直され、描き直しが遅れた行がずれて見えていました。
そこで、中身が同じ行は、前のオブジェクトを使い続けるようにしました。
/** next のうち previous と中身が同じものは、previous のオブジェクトにする。すべて同じで順も同じなら previous をそのまま返す */
export function reuseUnchanged<T>(
previous: readonly T[],
next: readonly T[],
keyOf: (value: T) => string,
isSame: (a: T, b: T) => boolean
): readonly T[] {
const previousByKey = new Map(previous.map((value) => [keyOf(value), value]));
const reused = next.map((value) => {
const old = previousByKey.get(keyOf(value));
return old !== undefined && isSame(old, value) ? old : value;
});
return isSameList(reused, previous) ? previous : reused;
}
React は props が変わったかどうかを参照で比べるため、同じオブジェクトを使い続ければ、変わった行だけが描き直されます。
まとめ
買い物リストは追加ごとに 1 ドキュメントとし、追加を追記だけにすることで、同時に足しても変更が消えないようにしました。
同じ品名のまとめは表示のときに行い、品名の比べ方には検索と同じ規則を使っています。
並べ替えは動かした行に「間の値」を書くだけにし、書き込みの回数を抑えています。
次回は、電波の弱い店の中でもレシピを見られる仕組み、Firestore の差分同期の話を予定しています。