[{"data":1,"prerenderedAt":631},["ShallowReactive",2],{"content-/trip-family-members-ssot-refactor":3,"all-pages-for-dir":629,"og-image-/trip-family-members-ssot-refactor":630},{"id":4,"title":5,"body":6,"category":611,"description":612,"extension":613,"meta":614,"navigation":585,"ogImage":615,"path":616,"project_name":617,"published":618,"publishedAt":619,"seo":620,"stem":621,"tags":622,"todo":615,"unpublished":618,"updatedAt":615,"__hash__":628},"pages/2026-05/2026-05-29/trip-family-members-ssot-refactor.md","旅行ページの『参加者』をSingle Source of Truthへ寄せたリファクタ",{"type":7,"value":8,"toc":603},"minimark",[9,13,17,21,24,56,84,87,93,111,127,144,147,153,397,407,416,465,471,503,510,513,516,527,537,540,571,574,599],[10,11,5],"h1",{"id":12},"旅行ページの参加者をsingle-source-of-truthへ寄せたリファクタ",[14,15,16],"p",{},"家族旅行アーカイブの旅行詳細ページを眺めていて、上部の表示に手が止まった。「家族メンバー年齢」セクションには私・妻・長男・長女・次女の5名が正しく出ているのに、一覧ページの「参加者」列には古い仮の名前が残っていた。同じ「誰が行くか」を、別々の場所に二度書いていた。これを一つの源泉に寄せるリファクタをかけた。",[18,19,20],"h2",{"id":20},"何が二重管理になっていたか",[14,22,23],{},"旅行データを開いて見比べると、「参加者」を表す情報が2か所に散らばっていた。",[25,26,27,43],"ul",{},[28,29,30,38,39,42],"li",{},[31,32,33,34],"strong",{},"frontmatterの ",[35,36,37],"code",{},"members",": 旅行Markdownのフロントマターに手入力した参加者名。一覧ページ（",[35,40,41],{},"index.astro","）の「参加者」列がこれを読んでいた。中身は初期に置いた仮データのまま放置されていて、実際の家族構成とずれていた",[28,44,45,51,52,55],{},[31,46,47,50],{},[35,48,49],{},"family.ts","（local JSON由来）",": 家族の実体（role / label / 生年月）を持つファイル。旅行詳細ページ（",[35,53,54],{},"[...slug].astro","）の「家族メンバー年齢」セクションはこちらを読んで、旅行日時点の満年齢まで計算して出していた",[14,57,58,59,62,63,66,67,69,70,73,74,80,81,83],{},"家族情報そのものは個人情報なので、",[35,60,61],{},"src/data/family.local.json"," に置いて ",[35,64,65],{},".gitignore"," で除外し、",[35,68,49],{}," が ",[35,71,72],{},"import.meta.glob"," で読み込む形になっている。つまり",[31,75,76,77,79],{},"家族の正しい実体は最初から ",[35,78,49],{}," 側にあった","のに、一覧の参加者列だけが古い手入力の ",[35,82,37],{}," を見ていた、という構図だった。同じ事実を二度書けば、片方が腐るのは時間の問題だった。",[18,85,86],{"id":86},"どう寄せるか決める前に範囲を確認した",[14,88,89,90,92],{},"いきなりスキーマをいじる前に、",[35,91,37],{}," がどこで使われているかを Claude Code に洗い出させた。すると、",[25,94,95,102],{},[28,96,97,98,101],{},"旅行Markdownは ",[35,99,100],{},"2026-08-oita.md"," の1件だけ",[28,103,104,107,108,110],{},[35,105,106],{},"trip.data.members"," を参照しているのは ",[35,109,41],{}," の「参加者」列だけ",[14,112,113,114,116,117,119,120,123,124,126],{},"と判明した。参照箇所が1か所しかないなら、",[35,115,37],{}," を消して ",[35,118,49],{}," から導出するのは安全に倒せる。ここで一つ判断したのは、",[31,121,122],{},"将来サブセットの旅行（家族の一部だけが参加する旅行）にも耐える形にしておく","こと。全員参加が当たり前なら ",[35,125,37],{}," を消すだけでもよかったが、それだと「父と長男だけの旅行」のような将来を表現できない。",[14,128,129,130,140,141,143],{},"そこで、frontmatterには名前を一切持たせず、",[31,131,132,135,136,139],{},[35,133,134],{},"family"," の role を参照する任意フィールド ",[35,137,138],{},"participants"," を置く","方針に決めた。省略したら全員参加扱いにすれば、いまの旅行は ",[35,142,138],{}," を書かなくて済む。",[18,145,146],{"id":146},"実装",[14,148,149,150,152],{},"方針が固まったので、Claude Code に順に実装させた。核心は ",[35,151,49],{}," に追加した参加者解決ヘルパーだ。",[154,155,160],"pre",{"className":156,"code":157,"language":158,"meta":159,"style":159},"language-ts shiki shiki-themes vitesse-light vitesse-light","// 旅行の参加者を family（SSOT）から解決して年齢付きで返す。\n// roles を指定すると該当ロールのみ、省略（または空）なら家族全員。\nexport const participantsAt = (\n  referenceDate: Date,\n  roles?: ReadonlyArray\u003CFamilyRole>,\n): FamilyMemberWithAge[] => {\n  const all = familyAt(referenceDate);\n  if (!roles || roles.length === 0) return all;\n  const wanted = new Set(roles);\n  return all.filter((m) => wanted.has(m.role));\n};\n","ts","",[35,161,162,171,177,199,216,239,257,280,326,348,391],{"__ignoreMap":159},[163,164,167],"span",{"class":165,"line":166},"line",1,[163,168,170],{"class":169},"sxvE3","// 旅行の参加者を family（SSOT）から解決して年齢付きで返す。\n",[163,172,174],{"class":165,"line":173},2,[163,175,176],{"class":169},"// roles を指定すると該当ロールのみ、省略（または空）なら家族全員。\n",[163,178,180,184,188,192,196],{"class":165,"line":179},3,[163,181,183],{"class":182},"sHkkW","export",[163,185,187],{"class":186},"stQ0i"," const ",[163,189,191],{"class":190},"senZ8","participantsAt",[163,193,195],{"class":194},"shFtX"," =",[163,197,198],{"class":194}," (\n",[163,200,202,206,209,213],{"class":165,"line":201},4,[163,203,205],{"class":204},"s4oTP","  referenceDate",[163,207,208],{"class":194},": ",[163,210,212],{"class":211},"sSkh3","Date",[163,214,215],{"class":194},",\n",[163,217,219,222,225,227,230,233,236],{"class":165,"line":218},5,[163,220,221],{"class":204},"  roles",[163,223,224],{"class":186},"?",[163,226,208],{"class":194},[163,228,229],{"class":211},"ReadonlyArray",[163,231,232],{"class":194},"\u003C",[163,234,235],{"class":211},"FamilyRole",[163,237,238],{"class":194},">,\n",[163,240,242,245,248,251,254],{"class":165,"line":241},6,[163,243,244],{"class":194},"):",[163,246,247],{"class":211}," FamilyMemberWithAge",[163,249,250],{"class":194},"[]",[163,252,253],{"class":194}," =>",[163,255,256],{"class":194}," {\n",[163,258,260,263,266,268,271,274,277],{"class":165,"line":259},7,[163,261,262],{"class":186},"  const ",[163,264,265],{"class":204},"all",[163,267,195],{"class":194},[163,269,270],{"class":190}," familyAt",[163,272,273],{"class":194},"(",[163,275,276],{"class":204},"referenceDate",[163,278,279],{"class":194},");\n",[163,281,283,286,289,292,295,298,300,303,307,310,314,317,320,323],{"class":165,"line":282},8,[163,284,285],{"class":182},"  if",[163,287,288],{"class":194}," (",[163,290,291],{"class":186},"!",[163,293,294],{"class":204},"roles",[163,296,297],{"class":186}," || ",[163,299,294],{"class":204},[163,301,302],{"class":194},".",[163,304,306],{"class":305},"sz8Xr","length",[163,308,309],{"class":186}," === ",[163,311,313],{"class":312},"sM54T","0",[163,315,316],{"class":194},")",[163,318,319],{"class":182}," return",[163,321,322],{"class":204}," all",[163,324,325],{"class":194},";\n",[163,327,329,331,334,336,339,342,344,346],{"class":165,"line":328},9,[163,330,262],{"class":186},[163,332,333],{"class":204},"wanted",[163,335,195],{"class":194},[163,337,338],{"class":186}," new ",[163,340,341],{"class":190},"Set",[163,343,273],{"class":194},[163,345,294],{"class":204},[163,347,279],{"class":194},[163,349,351,354,356,358,361,364,367,369,371,374,376,379,381,383,385,388],{"class":165,"line":350},10,[163,352,353],{"class":182},"  return",[163,355,322],{"class":204},[163,357,302],{"class":194},[163,359,360],{"class":190},"filter",[163,362,363],{"class":194},"((",[163,365,366],{"class":204},"m",[163,368,316],{"class":194},[163,370,253],{"class":194},[163,372,373],{"class":204}," wanted",[163,375,302],{"class":194},[163,377,378],{"class":190},"has",[163,380,273],{"class":194},[163,382,366],{"class":204},[163,384,302],{"class":194},[163,386,387],{"class":204},"role",[163,389,390],{"class":194},"));\n",[163,392,394],{"class":165,"line":393},11,[163,395,396],{"class":194},"};\n",[14,398,399,400,402,403,406],{},"表示用には ",[35,401,191],{}," の上に薄く ",[35,404,405],{},"participantLabelsAt","（labelの配列を返すだけ）を重ねた。一覧の列は名前だけ欲しい、詳細ページは年齢付きの実体が欲しい、という需要の違いをこの2段で吸収した。",[14,408,409,410,412,413,415],{},"次にスキーマ。必須の自由テキスト ",[35,411,37],{}," を、任意のrole参照 ",[35,414,138],{}," に置き換えた。",[154,417,419],{"className":156,"code":418,"language":158,"meta":159,"style":159},"// 参加者は family（src/lib/family）の role を参照（例: 'self' | 'spouse' | 'child1'…）。\n// 省略時は家族全員が参加した扱い。氏名はここに持たず family を SSOT とする。\nparticipants: z.array(z.string()).optional(),\n",[35,420,421,426,431],{"__ignoreMap":159},[163,422,423],{"class":165,"line":166},[163,424,425],{"class":169},"// 参加者は family（src/lib/family）の role を参照（例: 'self' | 'spouse' | 'child1'…）。\n",[163,427,428],{"class":165,"line":173},[163,429,430],{"class":169},"// 省略時は家族全員が参加した扱い。氏名はここに持たず family を SSOT とする。\n",[163,432,433,435,438,441,443,446,448,451,453,456,459,462],{"class":165,"line":179},[163,434,138],{"class":190},[163,436,437],{"class":194},":",[163,439,440],{"class":204}," z",[163,442,302],{"class":194},[163,444,445],{"class":190},"array",[163,447,273],{"class":194},[163,449,450],{"class":204},"z",[163,452,302],{"class":194},[163,454,455],{"class":190},"string",[163,457,458],{"class":194},"()).",[163,460,461],{"class":190},"optional",[163,463,464],{"class":194},"(),\n",[14,466,467,468,470],{},"あとは表示側を ",[35,469,134],{}," 由来に切り替えるだけ。",[25,472,473,485,494],{},[28,474,475,477,478,481,482],{},[35,476,41],{}," の「参加者」列を ",[35,479,480],{},"participantLabelsAt(trip.data.startDate, trip.data.participants).join('、')"," に変更。空なら ",[35,483,484],{},"—",[28,486,487,489,490,493],{},[35,488,54],{}," は ",[35,491,492],{},"participantsAt(trip.data.startDate, trip.data.participants)"," で参加者サブセットを解決（省略時は家族全員）",[28,495,496,497,499,500,502],{},"旅行のfrontmatterから古い ",[35,498,37],{}," ブロックを丸ごと削除（全員参加なので ",[35,501,138],{}," も書かない）",[14,504,505,506,509],{},"どちらのページも基準日に ",[35,507,508],{},"trip.data.startDate","（旅行の開始日）を渡しているので、年齢は「旅行時点の満年齢」で揃う。",[18,511,512],{"id":512},"検証",[14,514,515],{},"実装後、両ページをdevサーバーで確認した。",[25,517,518,524],{},[28,519,520,521,523],{},"旅行詳細ページ: エラーなし。「家族メンバー年齢」は ",[35,522,49],{}," から私・妻・長男・長女・次女の5名がそのまま出ている",[28,525,526],{},"一覧ページ: 「参加者」列が古い仮データから「私、妻、長男、長女、次女」に変わった",[14,528,529,530,533,534,536],{},"詳細ページの5名と一覧の参加者列が",[31,531,532],{},"完全に一致","し、frontmatterの検証もパスした。これで「誰が行くか」を聞かれたら、",[35,535,49],{}," という一つの場所だけを見ればよくなった。",[18,538,539],{"id":539},"学び",[25,541,542,551,563],{},[28,543,544,547,548,550],{},[31,545,546],{},"同じ事実を二度書いた瞬間に、片方は腐り始める。"," 今回の ",[35,549,37],{}," はまさにそれで、初期の仮データのまま誰も直さず取り残されていた。「正しい実体はどこか」を一つに決めて、残りはそこから導出するのが一番安い",[28,552,553,556,557,559,560,562],{},[31,554,555],{},"消すだけでなく、将来の表現力を一段だけ足しておく。"," ",[35,558,37],{}," を消して全員固定にもできたが、role参照の ",[35,561,138],{},"（任意・省略時は全員）にしたことで、サブセット旅行という未来を閉じずに済んだ。やりすぎない範囲で逃げ道を1本残す",[28,564,565,556,568,570],{},[31,566,567],{},"リファクタ前の参照箇所の洗い出しが効いた。",[35,569,37],{}," の利用が1か所だけと分かっていたから、スキーマ変更を迷わず倒せた。影響範囲を先に確定させると、判断の速度が上がる",[18,572,573],{"id":573},"明日やること",[25,575,578,591],{"className":576},[577],"contains-task-list",[28,579,582,587,588,590],{"className":580},[581],"task-list-item",[583,584],"input",{"disabled":585,"type":586},true,"checkbox"," サブセット旅行（家族の一部だけ参加）の旅行データを1件作って、",[35,589,138],{}," にrole配列を指定したときの表示を実際に確認する",[28,592,594,556,596,598],{"className":593},[581],[583,595],{"disabled":585,"type":586},[35,597,49],{}," の年齢計算が「誕生月の1日生まれ」を仮定している点を、境界日でテストして挙動を固める",[600,601,602],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .sSkh3, html code.shiki .sSkh3{--shiki-default:#2E8F82;--shiki-dark:#2E8F82}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}html pre.shiki code .sM54T, html code.shiki .sM54T{--shiki-default:#2F798A;--shiki-dark:#2F798A}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":159,"searchDepth":173,"depth":173,"links":604},[605,606,607,608,609,610],{"id":20,"depth":173,"text":20},{"id":86,"depth":173,"text":86},{"id":146,"depth":173,"text":146},{"id":512,"depth":173,"text":512},{"id":539,"depth":173,"text":539},{"id":573,"depth":173,"text":573},"dev","家族旅行アーカイブで、frontmatterの手入力membersとfamily.tsの二重管理を解消。参加者をfamily由来に一本化し、スキーマをparticipantsへ置き換えた記録。","md",{},null,"/trip-family-members-ssot-refactor","family-trips",false,"2026-05-29T00:00:00.000Z",{"title":5,"description":612},"2026-05/2026-05-29/trip-family-members-ssot-refactor",[623,624,625,626,627],"リファクタリング","Astro","Single Source of Truth","スキーマ設計","TypeScript","ZovPzAKMC6qZeG4S1VzYesyUOfFJwoOiDkPgK5PYbYc",[],"https://log.eurekapu.com/og/blog/trip-family-members-ssot-refactor.png?v=2026-05-29T00%3A00%3A00.000Z&title=%E6%97%85%E8%A1%8C%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE%E3%80%8E%E5%8F%82%E5%8A%A0%E8%80%85%E3%80%8F%E3%82%92Single%20Source%20of%20Truth%E3%81%B8%E5%AF%84%E3%81%9B%E3%81%9F%E3%83%AA%E3%83%95%E3%82%A1%E3%82%AF%E3%82%BF&author=Kei%20Komatsu&sig=7740b039f8105d34",1782528845204]