開発family-trips

旅行ページの『参加者』を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.tsimport.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].astroparticipantsAt(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日生まれ」を仮定している点を、境界日でテストして挙動を固める