旅行ページの『参加者』をSingle Source of Truthへ寄せたリファクタ
家族旅行アーカイブの旅行詳細ページを眺めていて、上部の表示に手が止まった。「家族メンバー年齢」セクションには私・妻・長男・長女・次女の5名が正しく出ているのに、一覧ページの「参加者」列には古い仮の名前が残っていた。同じ「誰が行くか」を、別々の場所に二度書いていた。これを一つの源泉に寄せるリファクタをかけた。
何が二重管理になっていたか
旅行データを開いて見比べると、「参加者」を表す情報が2か所に散らばっていた。
- frontmatterの
members: 旅行Markdownのフロントマターに手入力した参加者名。一覧ページ(index.astro)の「参加者」列がこれを読んでいた。中身は初期に置いた仮データのまま放置されていて、実際の家族構成とずれていた family.ts(local JSON由来): 家族の実体(role / label / 生年月)を持つファイル。旅行詳細ページ([...slug].astro)の「家族メンバー年齢」セクションはこちらを読んで、旅行日時点の満年齢まで計算して出していた
家族情報そのものは個人情報なので、src/data/family.local.json に置いて .gitignore で除外し、family.ts が import.meta.glob で読み込む形になっている。つまり家族の正しい実体は最初から family.ts 側にあったのに、一覧の参加者列だけが古い手入力の members を見ていた、という構図だった。同じ事実を二度書けば、片方が腐るのは時間の問題だった。
どう寄せるか決める前に範囲を確認した
いきなりスキーマをいじる前に、members がどこで使われているかを Claude Code に洗い出させた。すると、
- 旅行Markdownは
2026-08-oita.mdの1件だけ trip.data.membersを参照しているのはindex.astroの「参加者」列だけ
と判明した。参照箇所が1か所しかないなら、members を消して family.ts から導出するのは安全に倒せる。ここで一つ判断したのは、将来サブセットの旅行(家族の一部だけが参加する旅行)にも耐える形にしておくこと。全員参加が当たり前なら members を消すだけでもよかったが、それだと「父と長男だけの旅行」のような将来を表現できない。
そこで、frontmatterには名前を一切持たせず、family の role を参照する任意フィールド participants を置く方針に決めた。省略したら全員参加扱いにすれば、いまの旅行は participants を書かなくて済む。
実装
方針が固まったので、Claude Code に順に実装させた。核心は family.ts に追加した参加者解決ヘルパーだ。
// 旅行の参加者を family(SSOT)から解決して年齢付きで返す。
// roles を指定すると該当ロールのみ、省略(または空)なら家族全員。
export const participantsAt = (
referenceDate: Date,
roles?: ReadonlyArray<FamilyRole>,
): FamilyMemberWithAge[] => {
const all = familyAt(referenceDate);
if (!roles || roles.length === 0) return all;
const wanted = new Set(roles);
return all.filter((m) => wanted.has(m.role));
};
表示用には participantsAt の上に薄く participantLabelsAt(labelの配列を返すだけ)を重ねた。一覧の列は名前だけ欲しい、詳細ページは年齢付きの実体が欲しい、という需要の違いをこの2段で吸収した。
次にスキーマ。必須の自由テキスト members を、任意のrole参照 participants に置き換えた。
// 参加者は family(src/lib/family)の role を参照(例: 'self' | 'spouse' | 'child1'…)。
// 省略時は家族全員が参加した扱い。氏名はここに持たず family を SSOT とする。
participants: z.array(z.string()).optional(),
あとは表示側を family 由来に切り替えるだけ。
index.astroの「参加者」列をparticipantLabelsAt(trip.data.startDate, trip.data.participants).join('、')に変更。空なら—[...slug].astroはparticipantsAt(trip.data.startDate, trip.data.participants)で参加者サブセットを解決(省略時は家族全員)- 旅行のfrontmatterから古い
membersブロックを丸ごと削除(全員参加なのでparticipantsも書かない)
どちらのページも基準日に trip.data.startDate(旅行の開始日)を渡しているので、年齢は「旅行時点の満年齢」で揃う。
検証
実装後、両ページをdevサーバーで確認した。
- 旅行詳細ページ: エラーなし。「家族メンバー年齢」は
family.tsから私・妻・長男・長女・次女の5名がそのまま出ている - 一覧ページ: 「参加者」列が古い仮データから「私、妻、長男、長女、次女」に変わった
詳細ページの5名と一覧の参加者列が完全に一致し、frontmatterの検証もパスした。これで「誰が行くか」を聞かれたら、family.ts という一つの場所だけを見ればよくなった。
学び
- 同じ事実を二度書いた瞬間に、片方は腐り始める。 今回の
membersはまさにそれで、初期の仮データのまま誰も直さず取り残されていた。「正しい実体はどこか」を一つに決めて、残りはそこから導出するのが一番安い - 消すだけでなく、将来の表現力を一段だけ足しておく。
membersを消して全員固定にもできたが、role参照のparticipants(任意・省略時は全員)にしたことで、サブセット旅行という未来を閉じずに済んだ。やりすぎない範囲で逃げ道を1本残す - リファクタ前の参照箇所の洗い出しが効いた。
membersの利用が1か所だけと分かっていたから、スキーマ変更を迷わず倒せた。影響範囲を先に確定させると、判断の速度が上がる
明日やること
- サブセット旅行(家族の一部だけ参加)の旅行データを1件作って、
participantsにrole配列を指定したときの表示を実際に確認する -
family.tsの年齢計算が「誕生月の1日生まれ」を仮定している点を、境界日でテストして挙動を固める