コンテンツにスキップ
Documents for MorningStatusApp
Esc
↑↓移動↵開く⌘Jプレビュー
このページの内容

開発ノート

UI/UX設計

  1. UI Implementation (UI実装)

    • プレミアム感を出すために glassmorphism(グラスモーフィズム)を基調としたデザインを採用。
    • CSS Modules (*.module.css) を使用して、コンポーネント単位のスタイルをカプセル化。
    • Next.js App Router の generateStaticParams を活用し、メンバー詳細ページを静的に事前生成(SSG)することで高速な遷移を実現。
    • セマンティックHTML(<ruby>, <main>, <header> 等)を適切に使用し、アクセシビリティと構造化を意識。
    • Server Component から Client Component への props 渡し: page.tsx(Server Component)はデータ取得のみ担当し、インタラクティブな処理は 'use client' の子コンポーネントに委譲する構成が望ましい。member オブジェクトのような JSON シリアライズ可能なデータは props として渡せる。
  2. SNS表示は二重バッジではなく単一状態に統一(#224)

    • 「正常性」と「更新有無」は別軸だが、UIは単一バッジ(排他)にした方が運用時の読み取りミスが少ない。
    • 推奨状態:
      • ok + hasRecentUpdate=true → 更新あり(緑)
      • ok + hasRecentUpdate=false → 更新なし(灰)
      • error → 取得エラー(赤)
      • skipped/unknown → 未確認(黄)
    • 上記状態は member.snsCheck に保存し、フロントは snsCheck をそのまま表示する構造にするとロジックが単純になる。
  3. Client Component の useState は router.refresh() でリセットされない(#314)

    • Next.js App Router では router.refresh() を呼ぶとサーバーコンポーネントが再実行されて新しい props が生成されるが、Client Component の useState はその props で上書きされない。
    • そのため、ミューテーション(PUT/POST)成功後に UI を最新状態に保つには、APIレスポンスのデータで setState するのが確実。
    • NG パターン:
      // 保存前に組み立てたクライアント側データで state を更新 → サーバーの正規化結果が反映されない
      setMember(memberToSave);
      router.refresh();
    • OK パターン:
      // APIレスポンスのデータで state を更新 → サーバーが確定した最終状態を表示できる
      const data = await response.json();
      setMember(data.member as Member);
      router.refresh();
    • router.refresh() は ISR キャッシュの無効化(次回アクセス時に最新データを取得)のためにはなお必要。
  4. prefers-color-scheme による CSS カスケードが入力コンポーネントに波及する問題

    • globals.css で prefers-color-scheme: dark を使い、body の color を明るい色に設定していた場合、入力系コンポーネント(<input>, <textarea>, <select> 等)に bg-* と text-* を明示指定していないと、ダークモードで背景が白のままテキスト色だけが白くなり文字が見えなくなる。
    • 対策: 全入力系コンポーネントに bg-white text-gray-900(またはそれに相当するスタイル)を明示指定する。1つのコンポーネントで問題を発見した場合、同じパターンを持つ他コンポーネントも確認すること。
    • body レベルの色指定は全子要素に波及するため、入力要素はブラウザ・OS設定に依存せず明示的な背景色・テキスト色を持つのが原則。
  5. 詳細ページの前後ナビゲーション順は一覧の並び順と一致させ、同値時のタイブレークも明示する

    • 詳細ページに「前へ / 次へ」を追加する場合、ユーザーが一覧画面で見ていた順序と一致していないと移動体験が不自然になる。
    • 対策: 前後ナビのソート条件は一覧画面と同じ基準にそろえること。さらに、日付などの主キーが同値になる場合に備えて、タイトルや名称によるタイブレークを実装とテストの両方で固定する。
    • 例:
      • リリース詳細: 現在の format と同じリリースだけを対象にし、releaseDate 降順、同日なら title 昇順
      • プレイリスト詳細: publishedAt 降順、同日なら name 昇順
    • 先頭・末尾・中間の3パターンに加え、同値タイブレークのテストも用意しておくと回帰を防ぎやすい(#462)。
  6. Figma Plugin API: setBoundVariableForPaint は opacity を保持しない

    • figma.variables.setBoundVariableForPaint() にペイントオブジェクトを渡す際、opacity フィールドを指定しても 保持されず 1.0(不透明)になる。
    • 半透明の背景(ボタン・バッジ・ナビゲーション pill 等)には変数バインドを使わず、直接 fill を指定すること:
      // NG: opacity が無視されて不透明な白になる
      node.fills = [figma.variables.setBoundVariableForPaint(
        { type: 'SOLID', color: { r: 1, g: 1, b: 1 }, opacity: 0.08 }, 'color', borderVar
      )];
      
      // OK: opacity を直接指定する
      node.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 }, opacity: 0.08, blendMode: 'NORMAL' }];
    • 白背景に白テキストという最悪の見た目になるため気づきにくい。use_figma で半透明 fill を含むノードを作成したら、必ず get_screenshot で即確認すること。
    • 発生箇所: メンバー一覧・ユニット一覧のナビゲーション pill(2026-03-31 ユーザー手動修正)。
  7. Figma Plugin API: HORIZONTAL レイアウトの行カードは counterAxisSizingMode = 'AUTO' にする

    • layoutMode = 'HORIZONTAL' のカード行を resize(W, 10) で初期化した後に counterAxisSizingMode = 'FIXED' を設定すると、高さが 10px 固定のままになる。パディングを設定しても展開されない。
    • HORIZONTAL フレームの counterAxis は高さ方向なので、**HUG('AUTO')**を使ってコンテンツ+パディング分を自動展開させること:
      // NG: height が resize() の初期値 10px のまま固定される
      row.resize(CONTENT_W, 10);
      row.layoutMode = 'HORIZONTAL';
      row.counterAxisSizingMode = 'FIXED'; // ← 10px 固定になる
      row.paddingTop = 16;                 // パディングを設定しても高さは変わらない
      
      // OK: AUTO(HUG)で高さをコンテンツに合わせる
      row.resize(CONTENT_W, 10);
      row.layoutMode = 'HORIZONTAL';
      row.primaryAxisSizingMode = 'AUTO';  // 幅: HUG(FILL で上書きされる)
      row.counterAxisSizingMode = 'AUTO';  // 高さ: HUG(パディング+コンテンツで展開)
      row.layoutSizingHorizontal = 'FILL'; // appendChildの後に設定
    • 発生箇所: 収録リリース一覧・収録楽曲の行カード(2026-04-04 layoutSizingHorizontal = 'FILL' 修正で対処)。
  8. state 更新後に DOM 操作が必要な場合は flushSync を使う(#669)

    • React の setState はデフォルトで非同期(次の render で反映)。そのため setState 直後に scrollIntoView などの DOM 操作を行うと、更新前の DOM に対して操作が実行されてしまう。
    • flushSync で state 更新を同期的に完了させてから DOM 操作を行うことで、更新済の DOM に対して確実に操作できる:
      import { flushSync } from 'react-dom';
      
      // NG: setExpandedTours が非同期のため、scrollIntoView 実行時にまだ折りたたまれている
      onSelect={(targetId) => {
        setExpandedTours((prev) => new Set([...prev, key]));
        // ↑ 次の render まで DOM に反映されない
      }}
      
      // OK: flushSync で展開を確定させてから scrollIntoView が実行される
      onSelect={(targetId) => {
        flushSync(() => {
          setExpandedTours((prev) => new Set([...prev, key]));
        });
        // ↑ flushSync 内の更新は同期的に DOM へ反映される
      }}
    • flushSync はパフォーマンスへの影響があるため、DOM 操作との同期が必要な局所的な箇所にのみ使うこと。
    • 発生箇所: MemberDetailView の TourJumpSelect.onSelect(#669)。
  9. Suspense ストリーミング分離:ページの一部を遅延フェッチに切り出すパターン(#881)

    • 親 page.tsx に「必ずすぐ表示したいデータ」だけを残し、「遅くても構わないデータ」は async サーバーコンポーネントに切り出して <Suspense fallback={null}> で包む。
    • 切り出したコンポーネントには、親がすでに取得済のデータ(メンバー一覧など)を props として渡し、自身は追加フェッチのみ担当させる。
    • fallback={null} は「表示しない」選択。スケルトン UI を見せたい場合は代わりに <PageLoadingSkeleton /> 等を渡す。
    • テスト時の注意: JSON.stringify は React 要素の type(関数参照)を消すため、コンポーネント名は JSON 文字列中に現れない。Suspense のラッパー構造は JSX ツリーを直接参照して検証する:
      const suspenseEl = result.props.children[N];
      expect(suspenseEl.type).toBe(Suspense);
      expect(suspenseEl.props.children.type).toBe(MyAsyncComponent);
  10. 画面設計書を実装と突き合わせる際は「戻る」リンクの文言・遷移先を必ず実コードで確認する(#1366)

    • 「トップに戻る」に相当するリンクは共通コンポーネント化されておらず、各 page.tsx に個別実装されている。そのため文言が ホームに戻る / メンバー一覧に戻る のようにページごとに食い違っていることがある(例: app/units/page.tsx・app/lives/page.tsx は「ホームに戻る」だが app/comparisons/page.tsx は「メンバー一覧に戻る」のまま)。設計書に書く際は必ず対象ページの実際の aria-label / リンクテキストをgrepしてから記載すること。他ページの表記をそのまま流用しない。
    • 同様に、一覧画面→詳細画面の「戻る」の遷移先も、実装によっては一覧そのものではなく別の中間ページやトップに飛ぶ非対称な設計になっていることがある(例: app/setlists/[id]/page.tsx はライブ経由で入った場合 /lives/[id](詳細)ではなく /lives(一覧)に戻る)。往路と復路を別々に確認すること。
  11. 一覧画面への常設導線を「データ0件なら非表示」の条件レンダリングに乗せない(#1404)

    • NewPostsSection の Instagram/TikTok/YouTube/Ameba 各セクションは、当該SNSの新着投稿が1件以上ある場合のみセクション(および /instagram 等一覧画面への唯一のリンク)を描画していた。そのため新着が0件の日は、その画面へトップページから到達する手段が完全になくなっていた。
    • 対策: セクションの見出し・一覧画面へのリンクは常に描画し、投稿が0件のときは中身だけを「本日の新着投稿はありません」のようなプレースホルダーに差し替える。可視性そのものをデータ件数に結びつけない。
    • 見落としやすい点: 個別セクションを常時表示に直しても、複数セクションの「いずれか1件でもあるか」を判定する集約フラグ(例: hasAnyUpdates)で親コンポーネント自体を丸ごと return null にしていると、全セクションが同時に0件の日にまったく同じ問題が再発する。個別の条件分岐だけでなく、その上位にある集約ガードの要否も合わせて確認すること。
  12. releaseIds を持たない「各期ユニット」に、releaseIds を持つ「企画ユニット」を活動期間が重複する形で追加すると、境界年のタイブレークで表示が誤解決される(#1426)

    • findActiveSubUnit(lib/unit-helpers.ts)の「境界年は activeFrom が最大(最新)の期を採用」というタイブレーク規則(実装#25 参照)は、タンポポ・プッチモニ・ミニモニ(第1/2期)のように、親ユニットの活動期間を 隙間なく分割 し releaseIds を持たない「各期ユニット」同士の重複を想定した設計だった。
    • minihamuzu・bakatonosama-minimonihime(#1419)のように、特定楽曲のみに紐づく releaseIds あり「企画ユニット」を、既存の各期ユニット(minimoni-1 等)と活動期間が重複する形で追加すると、企画ユニット側がタイブレークで優先され、企画ユニットに紐づかない本来の各期ユニットの楽曲まで誤って企画ユニット名で表示されてしまう。
    • 対策: findActiveSubUnit に対象リリースの releaseId を渡し、候補ユニットが releaseIds を持つ場合は自身の releaseIds に対象リリースが含まれることを追加条件にした。releaseIds を持たない各期ユニットは従来通り期間のみで判定する。
    • 今後の注意点: 親ユニットに新しい企画ユニット(parentId あり・releaseIds あり・特定楽曲のみ対象)を追加する際は、既存の各期ユニットと活動期間が重複しないか確認すること。重複していても本対策により誤表示は起きないが、念のため活動期間の設計時に意識するとよい。
  13. SVG <text> は自然な折り返しができない。省略なく全文表示したい可変長テキストは HTML/CSS ブロックに置き換える(#1472)

    • SVG の <text> 要素は CSS の white-space/overflow-wrap によるテキスト折り返しに対応しておらず、複数行にするには <text> を複数並べる手動実装が必要になる。文字数ベースで機械的に改行位置を決めると、英数字タイトルで単語の途中(例: "MUSIC" → "MUS"/"IC")で折れてしまう。
    • 行数を固定(例: 2行)し、溢れた分を … で省略する方式は、表示するテキストの長さが可変な場合に必ず一部のケースで全文を表示できなくなる。「全文を省略なく表示する」という要件とは根本的に両立しない。
    • 対策: 全文表示が要件の場合は SVG での手動折り返しをやめ、固定サイズの viewBox の代わりに <div> 等の HTML ブロック(overflow-wrap: break-word)で描画し、コンテナは aspect-ratio 固定をやめて min-height のみ指定してコンテンツに応じた可変高さにする。ブラウザの標準テキストレイアウトが単語境界を自動的に尊重するため、手動の文字数区切りロジックが不要になる。
    • 発生箇所: EventCard の media/topic/festival タイプのプレースホルダー(画像未設定時のタイトル表示、components/EventCard.tsx の TEXT_TITLE_TYPES)。
  14. 'use client' ページ内で process.env.DESKTOP_MODE を直接参照しても機能しない。デスクトップモード限定UIはServer Componentの親からpropsで渡す(#1477)

    • NEXT_PUBLIC_ プレフィックスの付かない環境変数(DESKTOP_MODE 等)はビルド時にクライアントバンドルへインライン化されない。ページ全体が 'use client' の場合、その中で process.env.DESKTOP_MODE === '1' と書いてもブラウザでは常に undefined 評価になり、デスクトップ限定UIの表示判定が機能しない。
    • 対策: page.tsx はServer Component('use client' を付けない)のまま残し、process.env.DESKTOP_MODE === '1' を評価した結果を isDesktopMode propとして子のClient Componentへ渡す(app/members/[id]/page.tsx → MemberDetailView の既存パターンと同じ)。既存ページ全体が 'use client' になっている場合は、クライアントロジックを別ファイルの XxxView.tsx('use client')に切り出し、page.tsx を薄いServer Componentラッパーに戻すとよい(app/search/page.tsx → SearchView と同型)。
    • 発生箇所: app/member-map/radar/page.tsx(レーダーチャート画面全体が 'use client' だったため、終端日設定機能の追加にあたり components/MemberMapRadarView.tsx に分離した)。
  15. 「最新のN件」表示は、鮮度の定義(例: 直近30日以内)をヒーロー表示側にも同じ条件で適用する。折りたたみ一覧側だけに適用して満足しない(#1503)

    • 配列の先頭(array[0])を無条件に「最新」として大きく表示する実装は、実際には何年も前のデータであっても鮮度不明のまま「最新」ラベル付きで提示してしまう。件数フィルタ(#1404, 開発ノート#11)とは別の失敗モードで、こちらは要素が0件になるのではなく「古いデータが新しいものとして誤認される」点が異なる。
    • 対策: 「近況履歴は直近30日以内のみデフォルト表示」のように鮮度の定義(カットオフ日数)が一度でもコードベースに存在するなら、同じデータソースを使う他の表示箇所(ヒーロー表示・バッジ・サマリー等)にも同じカットオフを適用し、鮮度条件を満たさない場合は「近況なし」等のプレースホルダーに切り替える。片方だけ直して終わらせない。
    • 発生箇所: LatestStatusSection(components/member-detail/)の「最新の近況」。近況履歴側の30日カットオフは既存実装済だったが、ヒーロー表示(「最新の近況」見出し直下の本文)には同じカットオフが適用されておらず、直近更新のないOGメンバーで数年前の投稿がそのまま「最新の近況」として表示されていた。
  16. 画面設計書で「デスクトップモード」のアクセス制限を書く際は、notFound()/403等の実現方法まで書かない。「デスクトップモードでのみ〜」で言い切れば足りる(#1521)

    • デスクトップモード編集画面(年別ライブ一覧・フェス詳細・セットリスト画面)の設計書作成時、当初「DESKTOP_MODE 環境変数が設定されている場合(DESKTOP_MODE=1)のみ表示。未設定時は notFound() を返す」のように実現方法まで書いていたが、レビューで「アクセス制限という見出しで『デスクトップモードのみ』と書けば、アクセスできないことは自明。それをどう実装するか(notFound()か403か)は基本設計のレベルでは不要な詳細」という指摘を受けた。
    • 「デスクトップモード」という用語自体もこれまで「デスクトップモード限定」「DESKTOP_MODE=1 時のみ」等、設計書ごとに表現が揺れていた。docs/design/common/environment-variables.md に「デスクトップモードとは」を新設し、DESKTOP_MODE=1 で動作する本アプリの実行モード(対義語: Web版)として定義した上で、既存の言及箇所(同期状況・楽曲リンク修正・レーダーチャート終端日設定画面)も含めて表現を統一した。
    • 対策: 「デスクトップモード」を使う設計書は environment-variables.md の定義を参照し、独自に説明を書き下さない。アクセス制限の記述は「デスクトップモードでのみ〜」の一文で足り、notFound()/403等のエラーハンドリング方式は詳細設計・実装フェーズで決めればよい。
  17. 「クライアント側処理が必要」は「Server Componentページ全体の分離が必要」を意味しない。既存の共有leafコンポーネントを先に探す(blume#101)

    • イベント詳細画面設計書の初版では、「戻る」にブラウザ履歴を使うクライアント処理が必要になることを理由に、Live/Festival詳細のような'use client'の*DetailViewへの画面構成の分離が必要、という結論を検証なく書いていた。
    • 実際にはcomponents/BackLink.tsxという共有クライアントleafコンポーネント(router.back()。履歴がなければfallbackHrefにフォールバック)が既に存在し、app/songs/[id]/page.tsx・app/releases/[id]/page.tsx・app/comparisons/[id]/page.tsxではServer Componentページに埋め込むだけで使われていた。
    • 対策: 「一部にクライアント側の処理が必要」という理由だけで画面構成全体の見直し(ページ全体の'use client'化、*DetailViewのような大きめのコンポーネントへの分割)を決め打ちしない。まず類似要件(戻る・トグル・小さなインタラクション)の既存実装を探し、leafコンポーネントの追加・流用で足りないかを確認してから、それでも不足する場合に初めて画面構成の分離を検討する。
  18. 共有データ構造に新しい種別(kind)を追加する際、そのデータを受け取る全コンポーネントで entity 種別を前提にしたリンク先のハードコードがないか監査する。Issue本文が挙げるコンポーネントだけでは不十分(MorningStatusApp#1622)

    • member_lives に加えて member_festivals を関連ライブセクション(RelatedLivesSection)に統合する実装で、Issue本文には「RelatedLivesSection.tsx は表示ロジックの変更要否を実装時に確認する」としか書かれていなかった。実際には同コンポーネントが会場名リンクを /lives/${liveId} に固定でハードコードしており、フェス出演の festivalId を混入させると存在しない URL を生成してしまう不具合があった。
    • さらに、relatedLives を元データとして別セクション(HometownLivesSection。ご当地ライブ)に派生させる箇所があり、こちらは Issue 本文に一切登場しなかったが同じ理由で同時に壊れる状態だった。
    • 対策: 既存の集約データ(relatedLives 等)に新しい種別を混入させる変更では、そのデータを直接消費するコンポーネントだけでなく、そこから flatMap/filter 等で派生データを作る全ての下流コンポーネントも grep 等で洗い出し、entity 種別(liveId/festivalId 等)を前提にしたリンク・アイコン・ラベルのハードコードがないか確認する。Issue本文の記述はスコープの上限ではなく出発点として扱う。
  19. 隠し機能のトリガーにドラッグ操作(スワイプ・矢印キーによる方向入力)を使うと、対象要素を小さく絞ってもスクロール・ネイティブジェスチャーとの衝突を避けきれない(MorningStatusApp#1571・morning-status-blume#128、世代マトリックス機能の設計時に判明)

    • 当初はタイトル要素に対する方向入力シーケンス(↑↑↓↓←→←→、コナミコマンド風。デスクトップは矢印キー、スマホはスワイプ)を検討したが、スマホでは上下スワイプが通常のスクロール操作と区別できず、スワイプ開始と同時にページ自体がスクロールしてタイトル要素が画面外へ流れてしまい、複数回の方向入力を同じ位置で続けられない問題が設計段階(実装前)で判明した。左右スワイプもブラウザのエッジスワイプ(戻る/進む)ジェスチャーと衝突しうる
    • タイトルを position: fixed/sticky で画面に固定する代替案も検討したが、隠し機能のためだけにページ全体の可視レイアウト・スクロール挙動を変えてしまう副作用が大きく、採用しなかった
    • 対策: ドラッグ(スワイプ)を伴わない離散的なクリック/タップに置き換える。トリガーとなるUI要素(例: タイトルの各単語)を独立したクリック対象に分割し、決められた順序でクリック/タップした場合のみ成立する方式にした。要素単位のクリック判定は方向入力の座標差分判定よりも当たり判定が大きく、ドラッグを伴わないためスクロール・ネイティブジェスチャーとは原理的に衝突しない。デスクトップのクリックとスマホのタップも同一の onClick イベントで扱えるため、入力手段ごとに別処理を持つ必要もなくなった
    • 視覚的フィードバック(進捗表示等)は意図的に出さない設計とした。進捗が見えると隠し機能の存在に気付かれやすくなり、「知っている人だけが辿り着ける」という前提が崩れるため
  20. 原仕様を簡略化する判断は、落とす機能を一覧にして承認を得てから設計書に落とす。設計書が簡略版になると、設計書基準の実装・レビューでは原仕様との差分が見えず、実装後に機能落ち(目的未達)として発覚する(MorningStatusApp#1571・MorningStatusApp#1650、morning-status-blume#141)

    • 世代マトリックスは、think-issueで原仕様(ノード形状・イベントの記号・縦線・世代交差のハイライト)を「関係性が見通せる程度のシンプルな表現に簡略化する」と決め、設計書も実装も、本人の誕生年(*)と子供の誕生年(+)を並べるだけの表示になった。設計書どおりに実装し、設計書を基準にレビューしたため、原仕様との差分は、実装後に「見渡せる」「重なりが見える」という目的を果たせないと分かるまで見えなかった
    • 対策: 原仕様の表現を簡略化・削除する場合は、落とす機能(何を、どの程度)を一覧にしてユーザーの承認を得てから、設計書に落とす。設計書には「備考:」として、簡略化した内容と理由を残す。実装・レビューでは、設計書との整合に加えて、原仕様の目的(この機能で何が見えるようになるか)に照らして確認する
    • 位置関係を示すイメージ図は、図の形を文字どおりの線や経路として読み取らず、実データを図の軸に当てはめて意図を確認してから設計に落とす(例: 生年を2026年時点の年齢に直して、学齢の区切りと突き合わせる)
  21. position: sticky の見出しと同じ表内で、セル内の要素に z-index を付けると、スクロール時にセルの内容が固定した見出しの上に重なって見える(MorningStatusApp#1650、世代マトリックスのグリッド)

    • 症状: 縦横にスクロールできる表で、列見出し(position: sticky; z-index: 1)を固定していた。各セルの記号を最前面に出すため、セル内の記号(position: relative; z-index: 1)を、帯・枠・縦線の層(position: absolute)より前に置いたところ、下へスクロールしたときに、見出しの領域へセルの記号がはみ出して見出しの氏名に重なった
    • 原因: セル(td)が重なり順の単位になっておらず、セル内の記号の z-index: 1 が、表全体の重なり順で見出しの z-index: 1 と同じ値で競合した。同値の場合は DOM で後ろにある tbody のセルが前面に出るため、thead の見出しの上に描かれた
    • 対策: セルに position: relative; z-index: 0 を付けてセルを独立した重なり順の単位(stacking context)にする。セル内の z-index はセルの中に閉じ、固定した見出し(z-index: 1 以上)を越えなくなる。セル内で層を重ねるときは、セルの重なり順の単位を先に作ってから、内側の z-index を決める
    • 併せて、共通レイアウトの最大幅(<main className="container"> の max-width)から特定の画面だけ抜けたい場合は、ページ側の maxWidth を消すだけでは足りない(外側のレイアウトが制限している)。ページ直下に印(data-wide-canvas)を付け、main.container:has(> [data-wide-canvas]) で max-width: none にすると、レイアウトのコンポーネントを画面ごとに分岐させずに済む

テスト手法

  1. Vitest の Date モックと waitFor の共存

    • vi.useFakeTimers() を全面適用すると setTimeout も偽装され、@testing-library/react の waitFor / findBy* が動作しなくなる場合がある。
    • Date のみを偽装したい場合は vi.useFakeTimers({ toFake: ['Date'] }) を使うと setTimeout は本物のまま保たれ、waitFor との競合を回避できる。
    • afterEach(() => vi.useRealTimers()) でリセットを忘れずに行うこと。
  2. Next.js API Route で global fetch をモックする(Vitest)

    • Vercel Blob URL や外部エンドポイントへの fetch を使う API ルートをテストする際は、vi.stubGlobal('fetch', vi.fn().mockResolvedValue(...)) でグローバルの fetch をモックする。
    • vi.mock ではなく vi.stubGlobal を使う理由: fetch はモジュールではなくグローバルに存在するため。
    • 正常系ではレスポンスの json メソッドに加えて ok: true もモックに含めること。res.ok チェックを実装している場合、ok が undefined(falsy)になると正常系テストが全て失敗する:
      vi.stubGlobal(
        'fetch',
        vi.fn().mockResolvedValue({
          ok: true,
          json: vi.fn().mockResolvedValue(mockData),
        }),
      );
    • エラー系テストでは ok: false, status, statusText を指定:
      vi.stubGlobal(
        'fetch',
        vi.fn().mockResolvedValue({
          ok: false,
          status: 404,
          statusText: 'Not Found',
        }),
      );
    • vi.stubEnv('MEMBERS_BLOB_URL', url) と組み合わせることで、環境変数と fetch 両方を beforeEach でリセット可能。
  3. モジュールレベル const と vi.stubEnv のテスタビリティ

    • const FOO = process.env.FOO のようにモジュールロード時に環境変数を評価している場合、テスト内で vi.stubEnv('FOO', 'value') を呼んでも既にキャッシュ済の値は変わらない。
    • 対策: スクリプト内で process.env.FOO を関数呼び出し時に都度参照するよう変更すると、vi.stubEnv が正しく機能するようになる。
    • モジュールレベルの const を廃止して関数内参照に変えることで、vi.resetModules() + 動的 import() なしでテストできる。
  4. 外部 API(Tavily)を呼び出す関数のテスト — fetch の順序制御

    • syncMembers は内部で Blob fetch(1回目)と Tavily fetch(2回目)の2つの fetch を順番に呼び出す。
    • vi.stubGlobal('fetch', vi.fn().mockResolvedValue(...)) で一律に同じレスポンスを返すと、Tavily fetch も Blob レスポンスを返してしまい意図した動作にならない。
    • 対策: mockResolvedValueOnce を使って呼び出し順にレスポンスを切り替える:
      vi.stubGlobal(
        'fetch',
        vi
          .fn()
          .mockResolvedValueOnce({ ok: true, json: async () => blobResponse }) // 1回目: Blob
          .mockResolvedValueOnce({
            ok: true,
            json: async () => tavilyResponse,
          }), // 2回目: Tavily
      );
    • 外部 API の関数(performSearch 等)は export して単体テスト可能にすること。
  5. Vitest でクラスコンストラクタをモックする際は function キーワードまたは class 構文を使う

    • vi.mock('exa-js', () => ({ default: vi.fn() })) のように SDK のデフォルトエクスポート(クラス)を vi.fn() でモックした場合、new Exa() は vi.fn() をコンストラクタとして呼び出す。
    • このとき mockImplementation(() => ...) にアロー関数を渡すと 「is not a constructor」 エラーになる。mockReturnValue(...) も内部でアロー関数を使うため同じエラーが発生する(Vitest の警告: "The vi.fn() mock did not use 'function' or 'class' in its implementation")。
    • 方法1 — function キーワード: mockImplementation に function キーワードを使った関数を渡す(インスタンスごとに返すオブジェクトを変えたい場合に有効):
      vi.mocked(Exa).mockImplementation(function() {
        return { search: mockSearch } as unknown as InstanceType<typeof Exa>;
      });
    • 方法2 — class 構文(Vitest v4 推奨): vi.mock のファクトリで実際の class を返すとコンストラクタとして正しく動作する。メソッドチェーンを伴うクラス(例: jose の SignJWT)に特に有効:
      vi.mock('some-sdk', () => {
        class MockSomeClass {
          methodA() { return this; }
          methodB() { return Promise.resolve('result'); }
        }
        return { SomeClass: MockSomeClass };
      });
    • InstanceType<typeof Exa> へのキャストは as unknown as InstanceType<typeof Exa> で行う(モックオブジェクトは全メソッドを持たないため直接キャストできない)。
    • 複数テストで呼び出しごとに返す値を変えたい場合は setupExaMock ヘルパーで mockSearch に mockResolvedValueOnce を積み上げ、mockImplementation でそのインスタンスを返す構造にするとシンプルになる(sync-status.test.ts 参照)。
  6. Server Component テストで子コンポーネントの props を検証する

    • await ServerComponent() は JSX ツリーを返すが、子コンポーネント(MemberCard 等)関数は呼び出されない(React element descriptor として埋め込まれるだけ)。
    • そのため vi.mocked(ChildComponent).mock.calls は常に空になる。vi.fn() に変えてもモック呼び出しは記録されない。
    • 対策: JSON.stringify(result) で JSX ツリーをシリアライズすると、props の boolean・string 値が "propName":value の形式で含まれる。文字列検索で prop の値を検証できる:
      const html = JSON.stringify(await ServerComponent());
      expect(html).toContain('"isRecentlyUpdated":true');
      expect(html).not.toContain('"isRecentlyUpdated":false');
    • 特定メンバーの props を個別検証するより、「全員が true/false になるように日付をモック」するシナリオでテストするとシンプルになる。
    • 文字列(例: dataLastUpdated)は JSX の children として埋め込まれた場合も JSON.stringify に含まれるため、既存パターン expect(html).toContain('2026-02-10') も同原理で動作している。
    • 注意: next/link の Link コンポーネントは JSON.stringify で循環参照エラーを起こす。サーバーコンポーネントのテストで Link を直接レンダリングする場合は、以下のように vi.mock で差し替えること:
      vi.mock('next/link', () => ({
        default: (props: Record<string, unknown>) => props,
      }));
      これにより href などの props がプレーンオブジェクトとしてシリアライズ可能になる。子コンポーネントを null に差し替える app/page.tsx のテストパターンとは異なり、ページが Link を直接使う場合に必要。
    • 注意: JSX で変数を展開すると children が配列になり toContain で検索できない。<p>Disc {disc}</p> は children: ["Disc ", 1] としてシリアライズされるため toContain('Disc 1') は失敗する。テンプレートリテラル {Disc ${disc}} にすると children: "Disc 1" の単一文字列になり検索できる(#360)。
    • 注意: インライン JSX を別コンポーネントに切り出すとテキスト検索が壊れる。リファクタリングでラベル文字列(例: 'Spotify')をインライン JSX から ExternalLinkBadge 等のコンポーネントに移動すると、JSON シリアライズ結果にはコンポーネントの props({"type":"spotify",...})しか含まれなくなり、toContain('Spotify') が失敗する。対策: コンポーネントが受け取る props(type・url 等)を検証するよう切り替える(例: toContain('"type":"spotify"'))。ラベル文字列の表示検証は、その専用コンポーネントのテスト(ExternalLinkBadge.test.tsx 等)に移譲する(#399)。
  7. 曜日依存ロジックを追加した場合の既存テスト修正パターン(#321)

    • new Date().getDay() を使うロジック(例: shouldSyncOG())を追加すると、既存テストが実行日の曜日に依存して結果が変わることがある。
    • 特に OG メンバーの処理を確認する既存テストは、OG が同期対象外の曜日に実行されると OG がスキップされて失敗する。
    • 対策: 該当テストに vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('YYYY-MM-DDTHH:mm:ssZ')) で特定の曜日に固定する。
    • 曜日確認: UTC の日付から new Date('YYYY-MM-DD').getDay() で確認できる(例: 2026-01-05 = 月曜 = 1)。
    • afterEach または テスト内の後処理で vi.useRealTimers() を忘れずに呼ぶこと。
  8. waitFor の条件は中間状態ではなく最終状態を待つこと(#343)

    • 非同期保存後の表示を検証する際、「保存ボタンが消えた」を waitFor の条件にすると、ボタンが「保存中…」に変わった瞬間(fetch 解決前)に通過してしまう競合状態が起きる。
    • 後続の同期アサーションはコンポーネントがまだローディング中の状態で実行され、意図したテキストが見つからず失敗する。
    • 特に vi.useFakeTimers がある describe ブロック内では、タイマー制御の影響で非同期解決順序が変わりやすく、他の環境で通っていたパターンがそのまま使えないことがある。
    • NG パターン:
      // 中間状態(保存中)でも成立するため通過が早すぎる
      await waitFor(() => expect(screen.queryByText('保存する')).not.toBeInTheDocument());
      expect(screen.getByText(/保存結果のテキスト/)).toBeInTheDocument(); // 同期アサーション → 失敗し得る
    • OK パターン:
      // 最終状態(表示モードへの切り替え完了)を直接待つ
      await waitFor(() => expect(screen.getByText(/保存結果のテキスト/)).toBeInTheDocument());
  9. 非 React コードの setTimeout を含む非同期ループのテスト

    • 再試行ロジックのような「async ループ内で setTimeout を使うコード」(React 非依存)は vi.useFakeTimers() を全面適用しても問題ない。
    • vi.runAllTimersAsync() はタイマーコールバックとマイクロタスクを繰り返し実行するため、複数回の再試行ループも一度の呼び出しで完走させられる。
    • 推奨パターン(再試行ロジックなど、タイマー完了後に動作を検証する場合):
      vi.useFakeTimers();
      const fetchMock = vi.fn()
        .mockResolvedValueOnce({ ok: false, status: 429, ... }) // 1回目: 失敗
        .mockResolvedValueOnce({ ok: true, json: async () => data }); // 2回目: 成功
      vi.stubGlobal('fetch', fetchMock);
      
      const promise = fetchSomething();
      await vi.runAllTimersAsync(); // 全タイマーを進める(再試行の待機をスキップ)
      const result = await promise;
      
      expect(fetchMock).toHaveBeenCalledTimes(2);
    • afterEach(() => vi.useRealTimers()) でリセットすること。vi.restoreAllMocks() はタイマーを戻さない。
    • 簡易パターン(ループ内ディレイをスキップするだけでよい場合・#596): vi.stubGlobal('setTimeout', (fn: () => void) => { fn(); }) を beforeEach で設定すると全 setTimeout が即時実行される。afterEach で vi.unstubAllGlobals() 済なら追加のクリーンアップ不要。vi.useFakeTimers() + runAllTimersAsync より設定が少なく、既存の await fn() 呼び出しをそのまま維持できる。
  10. vi.clearAllMocks() はモック戻り値もクリアする

    • vi.mock('some-module', () => ({ fn: vi.fn().mockResolvedValue('default') })) のようにファクトリ内で初期戻り値を設定しても、beforeEach で vi.clearAllMocks() を呼ぶと戻り値もリセットされる。
    • 結果としてモック関数が undefined を返し、後続の処理が予期せず失敗する場合がある。
    • 対策: clearAllMocks() を使う describe ブロックの beforeEach で戻り値を再設定する:
      vi.mock('bcryptjs', () => ({ hash: vi.fn().mockResolvedValue('hashed') }));
      
      import { hash } from 'bcryptjs';
      const mockHash = hash as ReturnType<typeof vi.fn>;
      
      beforeEach(() => {
        vi.clearAllMocks();
        mockHash.mockResolvedValue('hashed'); // ← 再設定が必要
      });
    • 呼び出し履歴のみクリアしたい場合は vi.clearAllMocks() の代わりに各モックの .mockClear() を使う方法もある。
  11. aria-invalid 属性は「出現」だけでなく「不在」と「解消」もテストする

    • フォームバリデーションのテストで aria-invalid="true" の検証のみ行うと、「初期表示時に誤って設定されている」「修正後も残り続ける」バグを見逃す。
    • 推奨パターン(3状態を明示的に検証):
      const input = screen.getByTestId('target-input');
      
      // 1. 初期状態: 属性が存在しないことを確認
      expect(input).not.toHaveAttribute('aria-invalid');
      
      // (バリデーション発火)
      
      // 2. エラー発生時: "true" であることを確認
      expect(input).toHaveAttribute('aria-invalid', 'true');
      
      // (修正アクション)
      
      // 3. 解消後: 属性が削除されたことを確認
      expect(input).not.toHaveAttribute('aria-invalid');
    • アクセシビリティ属性はブール値でも DOM では "true"/"false" 文字列または属性の有無で制御されることが多い。toHaveAttribute を使い変化を確認する。
  12. nullable な props が複数あるコンポーネントは組み合わせを網羅してテストする

    • isActive(bool)× currentDirection("asc" | "desc" | null)のように複数の nullable props がある場合、全組み合わせのうちテストが漏れやすいエッジケースが存在する。
    • 特に「フラグが true だが別の props が null」のケースは実装側の条件式で見落とされやすく、スクリーンリーダーへの誤読み上げなどの不具合につながる:
      // NG: currentDirection が null のとき "降順" と誤表示される
      aria-label={isActive ? `${label}(${currentDirection === 'asc' ? '昇順' : '降順'})` : label}
      
      // OK: null 性を独立してチェック
      aria-label={isActive && currentDirection
        ? `${label}(${currentDirection === 'asc' ? '昇順' : '降順'})`
        : label}
    • コンポーネントの状態遷移を把握してから UI 属性(aria-label 等)を実装すると、エッジケースの漏れを防ぎやすい。
  13. モックオブジェクトは型に準拠した完全なオブジェクトを返すこと

    • { valid: false, payload: null } のようにインターフェースの一部フィールドを省略したモックを使うと、実装が省略フィールドを参照するよう変更されたとき潜在バグが顕在化する。
    • 例: verifyToken の失敗結果を不完全にモックしていた場合、エラーコードルックアップ実装への変更で undefined が返り、HTTPステータスがデフォルト (200) になるバグが発生した。
    • 対策: モックは as unknown as T 等で型チェックを回避せず、インターフェース定義に合わせた完全なオブジェクトを返すこと:
      // NG
      mockFn.mockResolvedValue({ valid: false, payload: null });
      
      // OK(インターフェースに合わせて error / message も含める)
      mockFn.mockResolvedValue({ valid: false, error: 'INVALID_TOKEN', message: 'トークンが無効です' });
    • ハードコードされたステータスコードを定数マップへのルックアップに変えるリファクタリングは、不完全なモックによる潜在バグを顕在化させるトリガーになることがある。
  14. E2E テスト(Playwright)— getByLabel() と aria-label の競合

    • <label for="password"> を持つ入力欄と、aria-label="パスワードを表示" を持つボタンが共存すると、getByLabel('パスワード') が両方にマッチして strict mode violation エラーになる。
    • 対症療法(NG): getByTestId('password-input') に切り替える → テストの可読性が低下する。
    • 根本解決(OK): 実装側の aria-label を文脈から明らかな短い形(例: "表示" / "隠す")に変える → getByLabel が一意にマッチするようになる。
    • getByTestId は最終手段。実装を修正して Role/Label/Text ベースのロケーターを使えるようにすることを優先すること。
  15. E2E テスト(Playwright)— 安定化のための3つのアプローチ

    • Cold Start の高速化: playwright.config.ts の webServer.command を bun run dev --turbo にして Turbopack を有効化すると動的コンパイルが大幅に速くなる。
    • タイムアウト緩和: playwright.config.ts で timeout: 90000、navigationTimeout: 45000、expect.timeout: 15000 を設定。特に重い業務フローには test.slow() を適用するとそのテストのみタイムアウトが3倍になる。
    • 堅牢なセレクタとロード待機:
      • 特定の行を絞り込んでからボタンをクリックする: page.getByRole("row").filter({ hasText: "..." }).getByRole("button", { name: "詳細" })
      • ページ遷移直後に「読み込み中」表示の消失を待つ: await expect(page.getByText("読み込み中...")).not.toBeVisible()
    • E2E テストは「成功/失敗」だけでなく「環境負荷への耐性」の視点も必要。
  16. E2E テスト(Playwright)— テストと実装の乖離パターン

    • reuseExistingServer: true のローカル開発では発覚せず、CI 初回実行で発覚しやすいパターンが3つある:
      1. ロール不一致: getByRole("tab") を使っていたが実装は <Link>(role="link")だった。
      2. ラベル・ロール不一致: ボタンのテキストや要素種別(<button> vs <Link>)がコンポーネントと乖離していた。
      3. シードデータの固定日付: 固定された過去日付がデフォルトフィルター(当月 1 日〜今日)の範囲外で、一覧に表示されず失敗。
    • シードデータの日付は固定値を避け、実行日に対して動的に生成する:
      const now = new Date();
      const seedDate = new Date(now.getFullYear(), now.getMonth(), 1); // 当月1日
    • CI を導入すると「環境差異による失敗」が顕在化する。CI の最初の実行は既存テストの健全性チェックとしても有効。
  17. E2E テスト(Playwright)— API 経由データ事前準備でフォーム依存を排除する

    • フォーム操作で前提データを作成する E2E テストは、フォームにバグがあると E2E レベルでも同じように失敗しタイムアウトする。
    • page.request.post() で API を直接呼び出してデータを準備し、page.reload() で画面に反映させることで、フォームの状態管理に依存しない安定したテストが書ける。
    • E2E がタイムアウトで失敗する場合は、フォームの Submit が API バリデーションエラーを返していないかブラウザのネットワークログで確認すること。
  18. E2E テスト(Playwright)— 複数ブラウザ並列実行時の状態共有干渉

    • workers: 1 で Chromium → Firefox → WebKit を直列実行すると、全ブラウザが同一の永続データ(DB・Blob 等)を共有する。前のブラウザがデータを変更すると後のブラウザが想定外の状態を参照して失敗する。
    • 対策: GitHub Actions の strategy.matrix でブラウザごとに独立したジョブを用意し、各ジョブが独自のデータストアから起動するようにする:
      e2e:
        strategy:
          matrix:
            browser: [chromium, firefox, webkit]
          fail-fast: false   # 1ブラウザ失敗でも他を継続
        steps:
          - run: bunx playwright install --with-deps ${{ matrix.browser }}
          - run: bunx playwright test --project=${{ matrix.browser }}
          - uses: actions/upload-artifact@v4
            with:
              name: playwright-report-${{ matrix.browser }}  # ブラウザ別に命名(上書き防止)
    • 「Chromium では通るのに Firefox/WebKit で落ちる」場合はブラウザ固有の挙動より先にデータ汚染を疑う。
  19. Node.js 組み込みモジュール(fs)のフォールバックパステスト(#624)

    • vi.doMock('fs', factory) + vi.resetModules() + await import(...) の組み合わせは、Node.js 組み込みモジュールに対しては機能しない場合がある(Bun+Vitest 環境)。再ロードしても実モジュールが参照され続けるため、フォールバックパスが踏まれない。
    • 正しいアプローチ: トップレベルに vi.mock('fs', async () => {...}) を置き、vi.importActual で取得した実装をラップした vi.fn() を返す。これにより sync-discography.ts 内の import { readFileSync } from 'fs' が差し替えられ、vi.mocked(readFileSync).mockImplementationOnce(...) でテストごとに挙動を制御できる。
    • 既存テストへの影響分離: vi.mock('fs', ...) を追加すると、実ファイルを読む既存テスト(例: syncDiscography の正常系)も影響を受ける。manual-releases.json のように追加後にファイルが存在するようになったパスは、ファクトリ内でデフォルト返値(例: { releases: [] })を設定し、既存テストをファイル内容から分離すること。mockImplementationOnce で上書きすればフォールバックテスト側は正しく動作する。
    • vi.clearAllMocks() はコール履歴のみリセット(実装は保持)。mockImplementationOnce のキューはクリアされるため beforeEach での clearAllMocks() と組み合わせてもテスト間の干渉は起きない。
    • mockImplementationOnce はファイル名に関係なく「次の1回の呼び出し」を横取りする: readFileSync が units.json→manual-releases.json の順で呼ばれる場合、mockImplementationOnce で manual-releases.json の返値を設定しても、実際には最初の units.json の呼び出しが横取りされる。複数ファイルを読む場合は getMockImplementation() でオリジナル実装を保持し mockImplementation にファイル名判定を組み込む:
      const originalImpl = vi.mocked(readFileSync).getMockImplementation();
      vi.mocked(readFileSync).mockImplementation((...args: unknown[]) => {
        if (String(args[0]).includes('target-file.json')) {
          return JSON.stringify({ /* test data */ });
        }
        return originalImpl?.(...(args as Parameters<typeof readFileSync>));
      });
  20. Issue対応時のテスト実行フローを固定する(#767)

    • このプロジェクトのテストは Vitest 前提。Bun の組み込みテストランナーを直接呼ぶ bun test ... は使わない。vi.stubGlobal / vi.stubEnv / vi.unstubAllEnvs など Vitest API を使う既存テストが失敗する。
    • 個別テストを先に確認したい場合も、必ず package script 経由で実行する:
      bun run test scripts/sync-genius-links.test.ts
    • その後、品質チェックは AGENTS.md の順序で全体実行する:
      bun run test
      bun run lint
      bun run type-check
    • 変更差分の最終確認には git diff --check も有効。エディタや自動編集で意図しない空白変更・末尾空白が混入していないか確認できる。
    • 手順化:
      1. Issue本文・コメント・関連ノートを確認する。
      2. 影響範囲のファイルを特定する。
      3. 必要最小限の差分で実装する(フォーマット全体変更を避ける)。
      4. 回帰テストを追加または更新する。
      5. bun run test <対象テスト> で局所確認する。
      6. bun run test / bun run lint / bun run type-check を通す。
      7. git diff --check と git diff で不要差分がないことを確認する。
  21. lib/prisma.ts のモジュール初期化スローと間接インポートの罠(#856)

    • lib/prisma.ts は DATABASE_URL 未設定のままモジュールがロードされると即座にエラーをスローする(export const prisma = globalThis.prismaClient ?? createPrismaClient() がトップレベルで実行されるため)。
    • この影響は「直接インポート」だけでなく「間接インポート」にも及ぶ。テスト対象スクリプトが lib/prisma.ts をインポートするモジュールを import していれば、テストファイル自体が lib/prisma.ts を踏む。
    • 症状: テストが DATABASE_URL 環境変数が設定されていません で fail する。スタックトレースを見るとスクリプト本体でなく初期化時点のエラー。
    • 対策: テストファイルの先頭(他の import より前)に必ずモックを宣言する:
      const mockPrisma = vi.hoisted(() => ({
        release: { createMany: vi.fn().mockResolvedValue({ count: 0 }) },
        track: { upsert: vi.fn().mockResolvedValue({}) },
        trackLink: {
          deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
          createMany: vi.fn().mockResolvedValue({ count: 0 }),
        },
      }));
      
      vi.mock('../lib/prisma', () => ({ prisma: mockPrisma }));
    • Vitest の vi.mock はホイスティングされるため、import 文の前に書いても問題ない。
    • 間接インポートを経由するケース(例: patch-tampopo-unit-id.ts → sync-discography.ts → lib/prisma.ts)でも同様に vi.mock('../lib/prisma', ...) が必要。
    • mockPrisma を vi.hoisted で切り出して参照する理由はテスト#39を参照。vi.mock の戻り値を直接オブジェクトリテラルにすると、後から vi.mocked(prisma.xxx.yyy) や prisma.xxx as {...} でモックを参照する際に型エラー(TS2740/TS2352)を起こしやすい。
    • lib/releases.ts も lib/prisma.ts を使うため、getReleases を呼ぶスクリプトのテストでは vi.mock('../lib/releases', ...) も合わせて宣言すること。
  22. @types/react 19 では ReactElement.props が unknown 型になる(#1114)

    • React.ReactElement の型パラメータ P のデフォルトが any から unknown に変更されたため、result.props.children のような直接アクセスは TS18046: 'result.props' is of type 'unknown' エラーになる。
    • Server Component テストで JSX ツリーを直接検証するヘルパー(テスト手法 #6・#9 のパターン)では、props を参照する前に明示的なキャストが必要:
      const props = result.props as { children?: React.ReactNode };
      const children = Array.isArray(props.children) ? props.children : [props.children];
    • CI には type-check ジョブがないため、この種のエラーはローカルの bun run type-check で初めて発覚する。テストが実行時に通っていても型エラーが潜んでいることがある。
  23. vi.mock の importOriginal が別のモック対象モジュールを import している場合は vi.hoisted が必要(#1103)

    • トップレベルの const mockPrisma = {...} を vi.mock('../../lib/prisma', () => ({ prisma: mockPrisma })) のファクトリで参照するパターン(collect-tiktok-posts.test.ts 等)は通常動作するが、同じテストファイル内の別の vi.mock ファクトリが importOriginal() を使い、その実モジュールが lib/prisma を import している場合は破綻する。
    • 原因: importOriginal() がホイスト段階で実モジュール(例: lib/youtube)をロードし、その import チェーンが prisma モックファクトリを即時実行するため、const mockPrisma の初期化前に参照され Cannot access 'mockPrisma' before initialization (TDZ) エラーになる。
    • 対策: モックオブジェクトを vi.hoisted でラップして初期化をホイスト段階に巻き上げる:
      const mockPrisma = vi.hoisted(() => ({
        youtubePost: { findMany: vi.fn() },
      }));
      vi.mock('../../lib/prisma', () => ({ prisma: mockPrisma }));
      vi.mock('../../lib/youtube', async (importOriginal) => {
        const actual = await importOriginal<typeof import('../../lib/youtube')>();
        return { ...actual, fetchPlaylistId: vi.fn(), saveYoutubePosts: vi.fn() };
      });
    • vi.hoisted 内では vi.fn() を使えるが mockResolvedValue 等の初期値は beforeEach で設定すること(vi.clearAllMocks() で戻り値もクリアされるため、どのみち beforeEach での再設定が必要)。
  24. Windows + Bun 環境では playwright の chromium.launch() が無応答でハングする(#1271)

    • /run スキルで新規ページの動作確認用に Playwright スクリプトを bun script.mjs で実行すると、chromium.launch() の呼び出しで応答なくハングし、タイムアウトするまで戻ってこない(エラーも出ない)。
    • ブラウザ本体(~/.cache/ms-playwright/chromium-*)はインストール済でも発生する。原因は Bun のプロセス起動(bunのchild_process実装)と Playwright の Chromium 起動シーケンスの相性問題と推測される。
    • 対策: 同じスクリプトを node script.mjs で実行すると正常に起動・操作できる。Playwright を使った手動検証スクリプトは Bun ではなく Node.js で実行すること(node --version で利用可能か事前確認する)。
    • この問題はアプリ本体の実装やテスト(Vitest)には影響しない。あくまで Playwright による手動ブラウザ操作スクリプトに限定される。
  25. 手動検証用 Playwright スクリプトは /tmp ではなくプロジェクト配下から実行する(#1290)

    • node はESMの import をNode標準のモジュール解決アルゴリズムで解決するため、スクリプトの配置場所から親ディレクトリを辿って node_modules を探す。/tmp/script.mjs のようにプロジェクト外に置くと node_modules/playwright が見つからず ERR_MODULE_NOT_FOUND になる。
    • 対策: 検証用スクリプトはプロジェクト配下の一時ディレクトリ(.claude/tmp/ 等)に置いて node .claude/tmp/script.mjs で実行する。スクリーンショットの出力先自体は /tmp のままでよい(読み書きの話ではなく、モジュール解決の起点の話)。
    • モバイル/デスクトップなど複数幅の比較確認は、chromium.launch() を1回だけ行い、幅ごとに browser.newContext({ viewport }) で別コンテキストを作って同じ操作を流すと差分が出しやすい。
  26. 既存の共通関数に曜日依存ロジックを追加すると、その関数を間接的に通る無関係な既存テストまで曜日依存になる(#1307)

    • #7(曜日依存ロジックを追加した場合の既存テスト修正パターン、#321)は「曜日判定そのものをテストするケース」の固定方法を扱っているが、今回のように 既存の共通処理(fetchMemberStatus の一般Web検索フォールバック)に新しく曜日ゲートを追加すると、その処理を経由するだけの無関係な既存テスト(officialSns 未指定のメンバーで一般検索を検証するテスト等)まで実行日の曜日に結果が左右されるようになる。
    • 該当テストを1件ずつ vi.setSystemTime で固定するのは該当箇所が多いと現実的でない。
    • 対策: 影響を受ける describe ブロックの beforeEach で環境変数を全曜日(例: vi.stubEnv('WEB_SEARCH_SYNC_WEEKDAYS', '0,1,2,3,4,5,6'))にスタブし、無関係なテストを曜日非依存にする。曜日ゲート自体をテストするケースだけ、そのテスト内で vi.stubEnv を上書きして特定の曜日に絞り込む。
  27. JSON.stringify(要素) で構造を検証する既存テストがある箇所に新しい条件付き要素を追加する際、既存要素の内側(同じ children 配列)に混ぜ込むと既存アサーションが壊れる(#1333)

    • 例: <p>{count} 回</p> を toContain('[3," 回"]') で検証している既存テストに対し、{count} 回 {condition && <a>...</a>} のように同じ <p> の中へ条件付き要素を追加すると、condition が偽の場合でも children 配列に null が追加され [3," 回",null] に変化し、既存の厳密な配列一致アサーションが壊れる。
    • 対策: 新しい条件付き要素は既存の検証対象要素の内側に混ぜず、兄弟要素として追加する。既存要素の children 構造を変えなければ、既存テストの修正は不要になる。
  28. next dev(Turbopack)の .next/dev ビルドキャッシュが、コンパイル成功ログ後も古いコンポーネント出力を配信し続けることがある(#1324)

    • Server Component(.tsx)を編集して保存すると ✓ Compiled ログが出て一見反映されたように見えるが、起動し続けている同一 dev server セッションに対して curl・Playwright で新規ページ取得しても、追加した要素(今回は年表の月ラベル <div>)がレンダリング結果に含まれないことがあった。
    • 切り分けのため .next/dev/server/chunks/ssr/*.js を直接 grep すると新しいコードは含まれていたため、モジュール自体は再コンパイルされていたが、リクエストへ返す出力側では古いキャッシュが使われ続けていたと考えられる。
    • 対策: pkill -f "next dev" でサーバーを停止し、.next/dev ディレクトリを削除してから bun run dev で再起動すると最新の変更が確実に反映される。UI動作確認中に「保存したはずの変更が画面に出ない」場合は、まずこの手順を試すこと。
  29. mockResolvedValueOnce チェーンの先に新しい fetch 呼び出しを追加しても、呼び出し元が try/catch で包まれていればテストは壊れない(#1387)

    • vi.fn().mockResolvedValueOnce(A).mockResolvedValueOnce(B) のようにキューを積んだモックに対し、実装側で3回目以降の fetch 呼び出しを追加すると、キュー枯渇後は vi.fn() のデフォルト(undefined を返す)にフォールバックする。
    • undefined.ok へのアクセスは TypeError になるが、呼び出し元の関数全体が try { ... } catch { return null; } で包まれていれば例外は握りつぶされ、単に null が返るだけでテストはクラッシュしない。
    • sync-status.ts に Ameba 同期ステータス読み込み(getAmebaSyncStatusFromBlob、内部で独自に fetch を呼ぶ)を追加した際、既存の「Blob → Ameba RSS」の2回チェーンを持つテスト群(mockResolvedValueOnce 2連鎖)が3回目の呼び出しで意図せず null を受け取ったが、そのテスト自体は statusHistory の検証のみで新規フィールドを見ていなかったため無修正で通過した。
    • 教訓: 既存の mockResolvedValueOnce チェーンを持つテストに新しい fetch 呼び出しを追加するコード変更をした場合、「テストが通る」ことは「新しい呼び出しが意図通りのデータを受け取った」ことを保証しない。新しい呼び出しの戻り値を検証するアサーションが必要な場合は、そのテストでは明示的にモックを追加すること(本Issueでは describe('Ameba 同期ステータスの書き込み') を新設して個別に検証した)。
  30. 共通判定関数のマッチ対象(入力文字列の範囲)を変更すると、その関数を間接的に呼ぶ他ファイルのテストフィクスチャが気づかれずに壊れる(#1412)

    • attributeTikTokPostToMembers(lib/tiktok-attribution.ts)のマッチ対象をキャプション全文からハッシュタグのみに変更した際、直接のテスト(lib/tiktok-attribution.test.ts)だけでなく、同関数を内部で呼ぶ buildTikTokSummaryEntry(scripts/workflow/collect-tiktok-posts.ts)のテストも、本文中に名前を書いただけのキャプション('メンバーAとの1枚' 等)をフィクスチャに使っていたため軒並み失敗した。
    • 教訓: 実装#94の「同じ対象データへの判定ロジックの重複確認」の裏返しとして、共通関数のマッチ条件・入力範囲を変更する際は、直接のテストファイルを直すだけでなく、その関数を呼ぶ全ファイル(grep で呼び出し元を確認)のテストフィクスチャも同じ前提(この場合は「名前は本文ではなくハッシュタグに書く」)に揃っているかを、テスト実行前に確認すること。フィクスチャの前提が古いままだと bun run test(プロジェクト全体)を実行して初めて発覚する。
  31. vi.mock('fs', ...) で existsSync/writeFileSync 等をモックする際は、named export だけでなく default プロパティにも同じ mock 関数を含めないと scripts/ 配下の SUT には反映されない(#1449)

    • vi.mock('fs', async () => { const actual = await vi.importActual('fs'); return { ...actual, existsSync: vi.fn(actual.existsSync) }; }) のように named export だけを上書きする形だと、テストファイル自身が import { existsSync } from 'fs' する分には正しくモックされて見える。しかし scripts/workflow/collect-instagram-posts.ts 側の import { existsSync } from 'fs' は素通りし、実物の fs.existsSync(ネイティブ実装)にバインドされたままになる。existsSync.toString() を SUT 側とテスト側でログ出力すると、テスト側は vi.fn() のラッパー文字列、SUT側はネイティブ関数のソースという食い違いで判別できる。
    • 原因: {...actual} でスプレッドした時点で actual.default(Node組み込みモジュールのCJS interop用に元々存在するプロパティ)がそのまま実物を指した状態で残るため、default 経由でアクセスされた場合はモックが効かない。
    • 対策: named export と同じ vi.fn() インスタンスを default にも明示的に複製する。
      vi.mock('fs', async () => {
        const actual = await vi.importActual<typeof import('fs')>('fs');
        const existsSyncMock = vi.fn(actual.existsSync);
        const writeFileSyncMock = vi.fn(actual.writeFileSync);
        return {
          ...actual,
          existsSync: existsSyncMock,
          writeFileSync: writeFileSyncMock,
          default: { ...actual, existsSync: existsSyncMock, writeFileSync: writeFileSyncMock },
        };
      });
    • sync-discography.test.ts の既存パターンが default: {...} を含んでいたのはこの理由による(#19で理由まで踏み込んで記録されていなかったため、今回改めて特定した)。
  32. PlaywrightからReact管理下の<input type="range">へ値を設定する場合、.valueへの直接代入ではonChangeが発火しない(#1311)

    • UI動作確認スクリプトで時系列操作バーのスライダーを「マウスドラッグ」で操作しようとしたが、page.mouse.down/move/up によるネイティブ<input type="range">のドラッグ操作は座標がずれやすく安定しなかった。代わりに element.evaluate((el) => { el.value = '...'; el.dispatchEvent(new Event('input')) }) で値を直接設定・イベント発火する方式に切り替えたところ、スライダーの見た目(つまみの位置)は変わるのに、React側のonChangeハンドラが呼ばれず画面の表示(連動する日付ラベル等)が更新されなかった。
    • 原因: Reactは <input> の value プロパティに独自のsetterを被せて変更を検知しているため、DOM要素の .value に直接代入するとReactの変更検知をすり抜け、その後 dispatchEvent(new Event('input')) を呼んでもReactの合成イベントハンドラ(onChange)が実際の値変化として認識しない。
    • 対策: Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set で取得したネイティブの value setter を .call(input, value) の形で使って値を設定してから dispatchEvent する。これによりReactの変更検知を経由した正規の値変更として扱われ、onChange が正しく発火する。
      await slider.evaluate((el) => {
        const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
        nativeSetter.call(el, '42');
        el.dispatchEvent(new Event('input', { bubbles: true }));
        el.dispatchEvent(new Event('change', { bubbles: true }));
      });
    • 実際のユーザー操作(マウスでのドラッグ・クリック)はブラウザが生成する本物のイベントのためこの問題は起きない。Playwrightのテスト・動作確認スクリプトでプログラム的に値を設定する場合のみ注意が必要。
  33. 単一対象への処理を複数対象のループに拡張すると、mockResolvedValueOnce チェーンで固定回数の fetch を前提にした既存テストが軒並み壊れる(#1458)

    • collect-tiktok-posts.ts の main() を「公式アカウント1件を処理」から「OFFICIAL_TIKTOK_ACCOUNTS をループして複数アカウントを処理」に変更したところ、既存の main テスト(Phase 1 系・#1422 系)が軒並み失敗した。原因は各テストが vi.spyOn(global, 'fetch').mockResolvedValueOnce(...) を1〜2回分しかキューに積んでおらず、ループで追加された2アカウント目の fetch 呼び出しでキューが枯渇し vi.fn() の既定動作(undefined を返す)にフォールバックしたため。
    • 実装#29(#1387)の「try/catch で包まれていればテストは壊れない」ケースとは異なり、fetchUserPosts は res.ok を無条件で参照するため undefined.ok で即座に例外が投げられ、テストがそのまま失敗する(サイレントな見逃しにはならない)。
    • 対策: .mockResolvedValueOnce(...) チェーンの末尾に .mockResolvedValue(emptyPageResponse) を追加し、明示的にモックしていない残りの対象(今回は2アカウント目)への呼び出しを空レスポンスにフォールバックさせる。mockResolvedValueOnce の宣言順序に関わらず、mockResolvedValue は常に「Once キューが尽きた後のデフォルト」として扱われるため、チェーンのどこに書いても同じ効果になる。
    • 呼び出し回数アサーションも合わせて見直すこと: toHaveBeenCalledTimes(N) のようなアサーションは対象数の増加分だけ機械的にずれる(1アカウント→2アカウントなら単純倍増とは限らず、ページネーション条件によって加算量が変わる)。mockImplementation で無条件 hasMore: true を返すMAX_PAGES上限テストのように、ループ対象が増えるとページ取得回数がその分単純に加算されるケースもあるため、各テストの意図(何をassertしたいか)に立ち返って再計算すること。
  34. オプションオブジェクトへのフィールド追加で壊れるテストを洗い出す際、grepはtoHaveBeenCalledWithだけでなくtoEqualも対象にすること(#1443)

    • put() の呼び出しオプションに cacheControlMaxAge フィールドを追加する作業で、影響を受けるテストを事前に grep -l "toHaveBeenCalledWith" | xargs grep -l "allowOverwrite" で洗い出し、expect.objectContaining を使っているファイルを「影響なし」と判定した。
    • patch-tampopo-unit-id.test.ts はこの絞り込みをすり抜けた。mockPut.mock.calls をループし expect(call[2]).toEqual({ ... }) で完全一致検証する書き方だったため、toHaveBeenCalledWith という文字列を含まず、grep 対象から漏れていた。実行するまで気づかず、テスト失敗で初めて発覚した。
    • 対策: オプションオブジェクトへのフィールド追加の影響範囲調査は toHaveBeenCalledWith に限定せず、toEqual(および toStrictEqual)も横断して grep すること。完全一致アサーションは呼び出し形式(expect(mockFn).toHaveBeenCalledWith(...) か expect(mockFn.mock.calls[N]).toEqual(...) か)が複数存在しうるため、アサーションメソッド名で絞るより、変更対象のフィールド名(例: allowOverwrite)と toEqual/toHaveBeenCalledWith の両方を組み合わせて確認する方が確実。
  35. 見出し文言の変更を.not.toContain(旧文言)で検証する際は、JSONキーで範囲を絞ること(#1491)

    • AmebaPage のタイトルを「Ameba ブログ」から「Ameba」に変更した際、退行防止のつもりで expect(html).not.toContain('Ameba ブログ') を書いたところ、同じ画面の説明文「メンバーの Ameba ブログ リンク集」に旧文言の部分文字列がそのまま含まれており、無関係な箇所で意図せずテストが落ちた。
    • html は React 要素ツールを JSON.stringify したもの(.claude/skills/implement-issue で使われるページテストの定番パターン)のため、見出しであろうと説明文であろうと同じ文字列表現で埋め込まれる。旧タイトル文字列が別の場所(説明文・aria-label 等)に正当な理由で残っている場合、単純な .not.toContain(旧文言) は偽陽性を起こす。
    • 対策: 見出しテキストの変更を確認する .not.toContain は、対象要素の JSON キーまで含めて絞り込む(例: .not.toContain('"children":"Ameba ブログ"'))。旧文言が他の文脈で残り続けることが仕様上正しいのか、実装漏れなのかを区別できる。
  36. optionalプロパティの「未指定」をテストする際、プロパティを明示的にundefinedにするのと、プロパティ自体を省略するのとを区別する(#1527)

    • slug?: string | nullのような型に対し、テスト用オブジェクトを{ ...baseInput, slug: undefined }のように作ると、obj.slugの実行時の値としては省略時と同じundefinedになるため、??/||を使った実装コードの分岐としては見分けがつかない。
    • しかし実際の入力(JSON.parseされたリクエストボディ等)で「未指定」を表すのはslugキー自体が存在しない状態であり、slug: undefinedという値を明示的に持つオブジェクトにはならない(JSON.stringifyはundefined値のプロパティを出力しない)。「明示的にundefinedを代入したオブジェクト」だけをテストに使うと、実際の入力形状とズレたまま気づかない。
    • 対策: 「未指定」を検証するテストでは、分割代入(const { slug, ...rest } = baseInput、未使用の分割変数にはeslint-disable-next-line @typescript-eslint/no-unused-varsを付与)でプロパティ自体を持たないオブジェクトを作り、それを「未指定」ケースの入力として使う。null・空文字列など他の「空」表現と並べてit.eachでパラメタライズすると、それぞれの違いが一目で分かる。
  37. 日付依存のUI機能をブラウザで手動確認する際、実行時の実日付にたまたま対象データが存在するとは限らない。システム日時を対象データの日付に合わせてから確認すること(OnThisDaySection、#1531)

    • 「過去のこの日」セクションのライブ時刻00:00非表示バグ修正(#1531)のPRで、動作確認のスクリーンショットを実行時の実日付のまま撮影したところ、たまたま該当するライブイベント(時刻00:00)が存在しない日だったため、修正が効いているかどうかの確証にならないスクリーンショットになっていた。
    • この種の不備はレビュー・マージの段階まで気づかれにくい。スクリーンショット上は「正常に表示されている」ように見えるため、バグ再現条件(今回であれば時刻00:00のライブが表示されている年)を実際に含んでいるかどうかを撮影者自身が確認しない限り、見た目だけでは判別できない。
    • 対策: 「過去のこの日」「今日は何の日」のような参照日(referenceDate)に基づいて表示内容が変わる機能を手動確認する際は、対象データの日付が分かっているなら、実行環境(OS)のシステム日時をその日付に合わせてから確認する。これにより、実行するたびに表示内容が変わってしまう日付依存機能でも、意図したデータで再現性のある確認ができる。
  38. Electrobunデスクトップアプリのランチャー(desktop/src/bun/index.ts)はelectrobun/bunをトップレベルimportしているため、Vitest(Node実行)から直接テストできない(#1535)

    • electrobun/bunは内部でbun:ffiのdlopenをモジュールスコープで呼び出している(node_modules/electrobun/dist/api/bun/proc/native.ts)。Bunランタイムではなく Node 上で動く Vitest からこのファイルを import すると、bun:ffi が存在しないため即座に失敗する。
    • 対策: プラットフォーム判定・パス解決のような electrobun/bun に依存しない純粋ロジックは、electrobun/bun を import しない別ファイルに切り出す(例: desktop/src/bun/resolve-paths.ts の resolveBunExecutablePath)。切り出した関数だけを Vitest でテストし、index.ts 側は薄いエントリポイントとして呼び出すだけにする。
    • #1535(macOSでのbunバイナリパス解決バグ)の修正時にこのパターンを初適用。このパス解決ロジックが以前 index.ts 内にベタ書きされ未テストだったこと自体がバグを生んだ一因だったため、今後 desktop/ 配下にロジックを追加する際も同様に分離すること。
  39. vi.mocked(実import.model.method) や prisma.model as {...} によるPrisma Delegate型のキャストは、selectで絞った戻り値の型不足(TS2740)や型の重なり不足(TS2352)を引き起こす(MorningStatusApp #1574)

    • PrismaのfindMany等は多数のオーバーロードを持つジェネリック関数。実際の呼び出しではselect: { trackId: true, title: true }のように一部フィールドだけを取得していても、vi.mocked(prisma.track.findMany)はその呼び出し固有のオーバーロード解決を認識せず、フルモデル型(Prisma生成の全フィールド)を要求する型を返す。そのため.mockResolvedValue([{ trackId, title }])のような絞り込んだフィクスチャがTS2740(プロパティ不足)で弾かれる。
    • 同様にprisma.event as { findMany: Mock<...>; update: Mock<...> }のような直接キャストも、PrismaのEventDelegate型と手書きのモック型が構造的に十分重ならずTS2352になる。
    • 対策: vi.mock('../../lib/prisma', ...)のモック定義をvi.hoisted(() => ({ track: { findMany: vi.fn(), ... }, ... }))で作ったmockPrismaに置き換え、テスト本体では実importのprismaを経由せずmockPrisma.track.findMany.mockResolvedValue(...)のように直接参照する。mockPrismaはPrismaの実型に一切触れない独立したオブジェクトなので、フィクスチャの型はモック関数の呼び出し内容だけから推論され、Prisma生成型との整合性チェックが働かなくなる(=絞り込んだフィクスチャがそのまま使える)。キャストは一切不要になる。
    • この対策は「フィクスチャの必須プロパティ不足(TS2740)」と「Delegate型のキャスト不備(TS2352)」という一見別種のエラーを同時に解消する。根本原因が「実Prisma型に触れているかどうか」という1点に集約されるため。
  40. jsdom は inline style のカラー値をブラウザ同様 rgb(r, g, b) 形式へ正規化する。style.borderLeft 等をテストで検証する際、コード側が16進数(#38bdf8)を指定していてもアサーション側で16進数の完全一致を書くと必ず失敗する(MorningStatusApp #1583)

    • React の style={{ borderLeft: '4px solid #38bdf8' }} はDOMに反映される際、jsdomのCSSStyleDeclaration実装がブラウザの挙動に合わせて色値をrgb(56, 189, 248)のような正規化済表現に変換する。ソースコード上の16進数表記とテストのアサーション文字列は一致しない。
    • 対策: 色を含むinline styleの完全一致検証をテストで書く場合は、16進数ではなくrgb(r, g, b)形式で期待値を書くか、色そのものより「ボーダーが適用されているか」だけを見たい場合はtoMatch(/4px solid/)のような部分一致にとどめる(既存のEventCard.test.tsxの大半のケースがこの部分一致パターンを採用している)。色の具体的な値まで検証したい場合のみrgb()形式へ変換して厳密一致させる。
  41. 既存の vi.mock('fs', ...) ファクトリを書き換えずに、1テストだけ実ファイルを読ませる方法(#19の簡易版、MorningStatusApp #1469)

    • #19は「ファクトリ自体をvi.importActualでラップし、他の全テストにもデフォルト返値を持たせる」という恒久対応だが、多数の既存テストが単純なvi.fn(() => JSON.stringify({...}))型のファクトリに依存している場合、ファクトリ自体の書き換えは既存テスト全体への影響範囲が広く、リスクも大きい。
    • 1件のテストだけが実ファイルを検証できればよい場合は、ファクトリはそのままに、テスト本体の中で const actualFs = await vi.importActual<typeof import('fs')>('fs') を呼び、そのテストのvi.mocked(readFileSync).mockImplementationOnce((...args) => actualFs.readFileSync(...(args as Parameters<typeof actualFs.readFileSync>)))で1回だけ実ファイル委譲すればよい(argsがunknown[]型のままだと型エラーになるためキャストが必要)。他のテストのモック挙動には一切影響しない。
    • data/inputs/manual-events.jsonのような実データファイルをスキーマ検証する回帰テストでは、期待値(件数等)をハードコードせず、同じactualFs.readFileSyncで読んだ内容から動的に算出すること(データファイルの件数は将来増減するため)。
    • 検証済の効果: 実際に不正なtype値をテスト実行時に一時的に混入させたところ、このテストが即座に失敗することを確認した(バリデーション失敗時のlogger.warn呼び出しをvi.spyOnで検知)。
  42. 既存レコードと入力の差分比較(diff)ロジックに新しい比較フィールドを追加する際、モックの「既存レコード」フィクスチャにそのフィールドを含めないと、undefined !== 実際の値により意図せずUPDATE判定される(MorningStatusApp #1585)

    • syncEventSongs()(scripts/workflow/sync-events.ts)はexistingSong.songTitle !== song.songTitleのような比較で、変更がなければUPDATEをスキップする設計。ここにartistNameの比較(existingSong.artistName !== song.artistName)を追加したところ、mockEventSong.findMany.mockResolvedValue([{ eventSongId: 1, seq: 1, songTitle: '曲A' }])のようにartistNameキー自体を含まない既存テストのモックフィクスチャでは、existingSong.artistNameがundefinedになる。入力側のsong.artistNameは(artist未指定曲でも)常にnullを持つ設計のため、undefined !== nullがtrueと評価され、「変更なし」を検証したいテストで意図せずUPDATEが呼ばれてしまう。
    • 対策: 比較ロジックに新しいフィールドを追加する場合、そのフィールドを参照する既存の「既存レコード」モックフィクスチャ全てに、新フィールドの値を明示的に追加する({ ..., artistName: null })。grepで対象関数の入力フィクスチャ({ seq:・trackIds:等、関数のパラメータ形状を特徴づける文字列)を洗い出し、1件ずつ確認すること。
    • 実装#39・テスト#34(フィールド追加で壊れるテストの洗い出し)と同種の教訓だが、対象が「呼び出しアサーション(toHaveBeenCalledWith)」ではなく「実装内部の比較ロジックが参照するモックの戻り値」である点が異なる。この種の壊れ方はテストの型チェックでは検出できず(モックはvi.fn()で型付けが緩い場合が多い)、実行してみて初めて「更新されないはずなのに更新された」という形で顕在化する。
  43. PlaywrightのgetByRole('link', { name: ... })はaria-label優先で「アクセシブルネーム」を照合する。可視テキストとaria-labelが異なるリンクをnameの部分一致・正規表現で探すと、可視テキストにマッチしても永久にヒットしない(MorningStatusApp #1595)

    • 動作確認スクリプトで「Topに戻る」という可視テキストを持つ<Link>をpage.getByRole('link', { name: /Topに戻る/ })で探索したところ、30秒タイムアウトした。該当の<Link>にはaria-label="ホーム(メンバー一覧)に戻る"が付与されており、アクセシブルネームの算出ルール上aria-labelが可視テキストより優先されるため、可視テキストに対する正規表現は一切マッチしない。
    • 対策: aria-labelが付いている要素をロールロケータで探す場合は、nameに可視テキストではなくaria-labelの文言(またはその一部)を指定する。可視テキストで確実に見つけたい場合はpage.getByText()やpage.locator('a:has-text(...)')など、アクセシブルネームの算出ルールに依存しないロケータを使う。
    • 副次的な学び: このタイムアウトの間、スクリーンショットには直前の画面(Next.jsのルート単位loading.tsxによるフォールバックUI)が写り込んでいたため、一見「別画面から遷移できていない」ように見えたが、実際の原因はロケータのミスマッチだった。ロケータのタイムアウトで詰まった場合、まずpage.url()とpage.title()で実際の遷移先を確認し、DOM構造の思い込みを排除してから原因を切り分けるとよい。
  44. 長時間起動しっぱなしのNext.js devサーバーは、新規追加したトップレベルルートディレクトリ(app/<新規>/page.tsx)に対して、ルート単位のloading.tsxフォールバックが解消されず表示され続けることがある(MorningStatusApp #1595)

    • 数日前から起動したままだったdevサーバー(Turbopack)に対して新規ページ(/average-age)のPlaywright動作確認を行ったところ、URLは正しく遷移する(waitForURLは成功する)のに、画面には常にルート直下のapp/loading.tsx(title="Hello! Project History"の共通スケルトン)が表示され続け、実際のページ内容がレンダリングされなかった。
    • サーバーログ自体はGET /average-age 200を高速(20〜30ms)に返しており、Server Component側の処理(Blobフェッチ等)が詰まっているわけではなかった。devサーバーを再起動(プロセスをkillしてbun run devを再実行)すると即座に解消した。原因はTurbopackの長時間セッションにおけるルート発見・HMRキャッシュの取りこぼしと推測される(未確定)。
    • 対策: 新規に追加したトップレベルルート(app/<new>/)のUI動作確認で「URLは変わるがコンテンツが更新されない」「ルート共通のローディングUIが表示され続ける」といった不可解な挙動に遭遇したら、まずps auxでdevサーバーの起動時刻を確認し、長時間(数時間〜数日)起動しっぱなしなら再起動してから再検証する。原因調査に時間をかける前に、まず疑ってよい高コスパな一手。
    • 知見28(.next/devビルドキャッシュが古い出力を配信し続ける)と症状の系統は近い(いずれもTurbopack長時間セッションのキャッシュ起因と推測される)が、知見28は「既存ページを編集した変更が反映されない」、本知見は「新規追加したルート自体がいつまでもloading.tsxのまま」という点でトリガーが異なる。.next/dev削除で知見28と同様に解消するかは未検証のため、次に遭遇した際はサーバー再起動だけでなく.next/dev削除の要否も切り分けるとよい。
  45. Server Component(Next.js App Router)の日付依存ロジックを未来日でPlaywright確認したい場合、page.clock.setFixedTime()はブラウザ側のJS時刻しか偽装できず効果がない。サーバー側(Node.js/Bun)プロセス全体のグローバルDateをpreloadスクリプトで上書きする方式も、Turbopackのdevサーバーでは○ Compiling ...のまま無限にハングし機能しない(MorningStatusApp #1595)

    • calculateAverageAgeTimeline()(Server Componentのpage.tsx内でawait getMembersFromBlob()後に呼ばれる)が未来日(結成記念日到来後)で正しくデータ点を含めるかをPlaywrightで確認しようとした際、まずpage.clock.setFixedTime(futureDate)を検討したが、これはブラウザのJS実行環境(window.Date)にのみ効き、Server Componentは別プロセス(Node.js/Bun)で事前にレンダリングされページ生成後にブラウザへ送られるため、サーバー側のnew Date()には一切影響しない。
    • 次に、Node.js/Bunプロセス全体のglobalThis.Dateをpreloadスクリプト(bun --preload ./fake-date-preload.cjs node_modules/.bin/next dev)で上書きする方式を試した。時刻を完全固定するパターン・Date.now()に一定オフセットを加算し実時間で進行させるパターンの両方を試したが、いずれもnext dev(Turbopack)が○ Compiling /average-age ...から一切進まずハングした。Turbopackの実処理はRustバイナリのサブプロセスとして動くため、JS側のDate上書きが届く層(Next.jsのJSオーケストレーション層)と届かない層(Turbopackネイティブ層)に時刻認識の食い違いが生じ、両者の間の待ち合わせ処理が壊れたと推測される(未確定)。
    • 今回有効だった方法: 対象のServer Component(page.tsx)内で日付計算関数に渡すnow引数を一時的に明示値(例: new Date('2027-09-14T03:00:00Z'))にハードコードし、devサーバーで実際に表示・Playwrightでスクリーンショットを撮影した後、その場でコードを元に戻す(コミットしない)。プロセス全体やブラウザ全体を偽装するより、狭い範囲を一時的に書き換える方が確実で副作用もない。
    • 恒久的な代替案として検討したが採用しなかったもの: lib/date.tsにNEXT_PUBLIC_TEST_NOW等の環境変数で現在時刻を上書きできるgetSystemDate()のような関数を追加し、本番コードからnew Date()の代わりに使う設計。日付依存機能のテストを今後も繰り返す前提なら有用だが、本番環境に「時刻を偽装できる入口」を恒久的に持ち込むリスク(環境変数の設定ミスで本番の日付判定が壊れる)とのトレードオフになるため、採用は都度の判断が必要(本Issueでは一時的な検証で十分だったため見送った)。
    • 教訓: Server Componentの日付境界ロジックを未来日で目視確認したい場合、まず「ブラウザ時刻を偽装するツール(Playwright Clock等)はサーバー側には効かない」ことを前提に置く。プロセス全体の時刻偽装はビルドツール(Turbopack等)との相性問題を引き起こしうるため、まずは対象コードの呼び出し箇所を一時的に書き換える最小侵襲の方法を試すこと。
  46. PlaywrightのgetByRole('alert')は、Next.jsの開発サーバーが常時持つルートアナウンサー(role="alert")に先にマッチし、アプリ自身のエラー表示を待てないことがある(MorningStatusApp #1632)

    • 症状: 合言葉フォームの不一致エラー(<p role="alert">)をpage.getByRole('alert').waitFor()で待った直後にtextContent()を検証したところ、エラーメッセージを含まず失敗した。サーバーは想定どおり401を返しており、アプリ側の不具合ではなかった。
    • 原因(推測、ルートアナウンサー要素の実体は未確認): next devはページ遷移の読み上げ用にrole="alert"の要素を常にDOMに持つ。waitFor()が送信直後(fetch完了前)にこの要素で先に解決し、アプリ側のエラー表示がまだ描画されていない時点の内容を読んでいた。
    • 対策: page.locator('form [role="alert"]')のように親要素で範囲を絞るか、表示文言(getByText('合言葉が正しくありません')等)で待つ。アプリ側のrole="alert"付与は正しい実装なので変更しない。
  47. Prismaモデルにカラムを追加し、そのモデルの値をnullだけを考慮する関数へ渡すようにすると、モデルを返すモック(findMany等)だけを設定した別のテストがundefinedで落ちる。影響確認は、変更した関数名だけでなく、モデルのモックでも洗い出す(MorningStatusApp#1545、syncEventsの統合テスト)

    • 症状: Festivalにtime・performanceTime(NULL許容)を追加し、convertFestivalsToEventsが代表時刻を求める関数(引数はDate | null)を呼ぶようにしたところ、全テストのうちsyncEventsの統合テスト1件だけが Cannot read properties of undefined (reading 'getUTCHours') で落ちた
    • 原因: 実際のPrismaの結果では、新しいカラムはnullかDateのどちらかで、undefinedにはならない。一方、そのテストは mockFestival.findMany.mockResolvedValue([{ festivalId, name, date }]) のように新しいカラムのキー自体を持たない行を返しており、関数にはundefinedが渡る。shouldShowTimeはnullだけを考慮しているため、undefinedで落ちた。テスト#42(既存レコードのフィクスチャにフィールドがない)と同種だが、今回は差分比較ではなく、null許容型の関数への入力として顕在化した
    • 見落とした理由: 影響確認を、変更した関数名(convertFestivalsToEvents)のgrepだけで済ませたため。この関数をsyncEvents経由で呼び、モックの返り値だけを設定しているテストは、関数名にヒットしなかった
    • 対策: モデルにカラムを追加する変更では、変更した関数名に加えて、そのモデルのモック(festival.findMany・mockFestival等)でもgrepし、返り値のフィクスチャに新カラム(null)を補う。これは、テスト実行の前に影響確認として済ませる。型付きの変数で持つフィクスチャはtscが検出するが、mockResolvedValueの引数は型が緩く、検出されない
  48. JSON.stringify(await ServerComponent()) は、ページ内でローカル定義された子コンポーネント(import されていない自前の関数、例: TrackCard)の出力を検証できない(MorningStatusApp#1658)

    • 原因: 関数値はJSONにシリアライズされず消えるため、{ type: TrackCard, props: {...} } の type が落ちて props だけが残る。子コンポーネント自身が実行されないため、その内部で分岐した表示(例: YouTube動画の有無で切り替わる「動画なし」プレースホルダー)は文字列化結果に一切現れない
    • この手法は、vi.mock で外出しした子コンポーネントを (props) => props に差し替えて props だけを検査する用途(app/lives/prefecture/[code]/page.test.tsx 等)には有効だが、ページ側にインラインで定義された表示ロジックの検証には使えない
    • 対策: ローカル定義コンポーネントの実際の描画結果を確認したい場合は render(await ServerComponent())(app/generational-matrix/page.test.tsx の先例)でReactに実行させ、screen クエリでアサートする
  49. bun run start -- -p <port> をバックグラウンド起動して繰り返しPlaywright検証する際、古いプロセスがEADDRINUSEで残ったまま新プロセスの起動が失敗すると、ビルド前後で内容が変わったのに検証は古いビルドのまま行われ、CSSチャンク404/500という紛らわしい症状で気づく(MorningStatusApp#1666)

    • 症状: rm -rf .next && bun run buildでCSSの変更を反映させたはずなのに、Playwrightでの実機検証(getComputedStyle等)を行うと該当ページのスタイルが一切適用されず(無地の白背景)、ネットワークログに.next/static/chunks/*.cssへの404または500エラーが出た
    • 原因: (bun run start -- -p 3100 > /tmp/nextstart.log 2>&1 &)をバックグラウンドで繰り返し実行していたが、pkill -f "next start"が実際のNode子プロセスを終了できておらず、古いサーバーがポートを掴んだまま残っていた。新しいサーバー起動はEADDRINUSEで失敗するが、バックグラウンド実行かつログを都度確認していなかったため気づかず、検証は古いビルド(既に.nextを削除・再生成した後のため、参照するチャンクハッシュが実際のファイルと一致しない)に対して行われていた
    • 対策: バックグラウンドでサーバーを再起動する前に、必ず対象ポートの既存リスナーを確認する(netstat -ano | grep ":<port>" または PowerShellのGet-NetTCPConnection -LocalPort <port>)。Git BashやPOSIXのpkillはWindows上のNode子プロセスを確実に終了できないことがあるため、確実に終了させるにはStop-Process -Id <PID> -Force(PowerShell、Windowsネイティブのプロセス終了)を使う。起動直後は/tmp/nextstart.logを必ず確認し、EADDRINUSE等の起動失敗が出ていないことを確認してから検証に進むこと

インフラ・設定

  1. スクリプトのTypeScript設定 (Scripts TypeScript Config)(MorningStatusApp #1572で更新)

    • scripts/ ディレクトリは専用の tsconfig.scripts.json で型チェックする(bun run type-check が tsc --noEmit と tsc --noEmit -p tsconfig.scripts.json の両方を実行)。備考: 当初(#504a19b)は tsconfig.json の exclude に scripts を入れて型チェック対象から完全除外していたが、これにより本番データ操作コードの型不備が長期間検出されない状態になっていた(MorningStatusApp #1568)
    • scripts/ 自身のコードは引き続き @/ エイリアスを使わず相対パス(例: ../types/member)でインポートする運用(既存コードの慣習)。ただし scripts/ から import される lib/*.ts(app側と共用のファイル)が内部で @/ を使っているため、tsconfig.scripts.json 側にも paths: { "@/*": ["./*"] } の設定が必要
    • import.meta.main(Bun固有API)は @types/bun を types に追加することで正式に型解決できる。これにより既存コードの (import.meta as ImportMeta & { main?: boolean }).main というキャスト回避策と、素の import.meta.main 直書きの両方が混在していても型エラーにならない
    • 将来のAPI実装のためのプレースホルダー引数(例: _query)には // eslint-disable-next-line @typescript-eslint/no-unused-vars を付与する。
  2. Vercel 本番環境でのファイル書き込み制限

    • Vercel のサーバーレス関数は /var/task(プロジェクトルート)が読み取り専用。fs.writeFileSync は EROFS: read-only file system エラーで失敗する。
    • 対策: fs.writeFileSync を独立した try-catch で囲み、code === 'EROFS' || code === 'EACCES' の場合は 503 を返してユーザーに明示的なメッセージを表示する。
    • クライアント側では response.ok が false の場合にレスポンスボディを読んで API のエラーメッセージを alert() に渡す。
    • この制限はローカル開発環境(bun run dev)には影響しない。本番でデータ編集を可能にするには Vercel Blob / KV 等の外部ストレージが必要。
  3. Vercel Blob SDK をスクリプト(Node/Bun)から使う際の注意点

    • put() はデフォルトで URL にランダムサフィックスを付与する(例: members-abc123.json)。固定 URL で上書き保存するには addRandomSuffix: false を必ず指定すること。これを省略すると呼び出しごとに異なる URL が生成され、MEMBERS_BLOB_URL の管理が破綻する。
    • Bun スクリプトでは BLOB_READ_WRITE_TOKEN 環境変数を put() の token オプションに明示的に渡すこと(Edge Runtime と異なりスクリプト環境では自動注入されない場合がある)。
    • スクリプトのテストでは vi.mock('@vercel/blob') と、データ取得方法に応じて以下のいずれかを組み合わせてモックする:
      • fs.readFileSync を使うスクリプト(ローカルファイルを読み込む場合):
        vi.mock('@vercel/blob', () => ({ put: vi.fn() }));
        vi.mock('fs', () => ({ default: { readFileSync: vi.fn() } }));
      • fetch で Blob URL を読み込むスクリプト(sync-status.ts 等):
        vi.mock('@vercel/blob', () => ({ put: vi.fn() }));
        vi.stubGlobal(
          'fetch',
          vi.fn().mockResolvedValue({
            ok: true,
            json: vi.fn().mockResolvedValue(mockBlobData),
          }),
        );
        ok: true を必ず含めること(res.ok チェックが実装されている場合、省略すると正常系テストが全て失敗する)。
  4. GitHub Actions の Environment secrets と Repository secrets の使い分け

    • GitHub には「Repository secrets」と「Environment secrets」の2種類がある。
    • Environment secrets(Production / Preview 等)はジョブに environment: <名前> を指定しないと注入されない。
    • ワークフローの env: にシークレット名を書いても、environment: 未指定なら値は空になる。
    • 注意: environment: を指定すると、その環境に登録されていないシークレットは env: に列挙していても undefined になる。一部だけ Environment secrets に置いている場合に他のシークレットが壊れるトラップがある。
    • 推奨: GitHub Actions から参照するシークレットは Repository secrets に統一する。Vercel 等のデプロイ環境との連携が不要なシークレット(API キー等)は Repository secrets で十分。
      # ✅ シンプルな構成(全て Repository secrets)
      jobs:
        sync:
          runs-on: ubuntu-latest
          steps:
            - run: bun scripts/sync-status.ts
              env:
                SEARCH_API_KEY: ${{ secrets.SEARCH_API_KEY }}
                BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
    • スクリプト内で「Dry run」メッセージが出る場合は、シークレットの注入漏れ(種別ミス含む)を疑うこと。
  5. Vercel Blob put() の allowOverwrite オプション

    • @vercel/blob v1.0.0 から、既存の Blob ファイルへの上書きには allowOverwrite: true の明示的な指定が必要になった(それ以前はデフォルトで上書き可能だった)。
    • 未指定の場合は BlobError: This blob already exists が発生する。
    • addRandomSuffix: false と組み合わせて固定 URL で上書き保存する場合は必ずセットで指定すること:
      await put('members.json', content, {
        access: 'public',
        contentType: 'application/json',
        addRandomSuffix: false,
        allowOverwrite: true, // ← v1.0.0 以降、上書きに必要
        token: process.env.BLOB_READ_WRITE_TOKEN,
      });
  6. ESLint 10 と eslint-plugin-react の互換性

    • ESLint 10 は context.getFilename() メソッドを削除した(ESLint 9 では非推奨だったが残存していた)。
    • eslint-plugin-react v7.37.x はこの API を使用しているため、ESLint 10 へのアップグレード時に以下のエラーが発生する:
      TypeError: Error while loading rule 'react/display-name': contextOrFilename.getFilename is not a function
    • eslint-config-next は eslint-plugin-react: '^7.37.0' を依存に持つため、eslint-config-next が eslint-plugin-react を ESLint 10 対応版に更新するまで ESLint 10 は使用不可。
    • 対応: ESLint 10 への更新は upstream(eslint-plugin-react / eslint-config-next)が対応次第実施する(Issue #144)。
  7. GitHub Actions で Prisma を使うスクリプトを実行する場合は bunx prisma generate が必須(#884)

    • bun install だけでは Prisma クライアント(.prisma/client/)は生成されない。bunx prisma generate を明示的に実行しないと Cannot find module '.prisma/client/default' エラーが発生する。
    • Prisma を使用するスクリプト(sync-discography.ts, sync-genius-links.ts, sync-playlist-links.ts 等)を実行するワークフローには必ず bun install の直後に以下のステップを追加すること:
      - name: Generate Prisma client
        run: bunx prisma generate
    • DATABASE_URL は prisma generate の実行に不要(スキーマ読み取りのみ)。スクリプトが DB 接続を必要とする場合にのみ env: に渡せばよい。
  8. server-only を Prisma ラッパー等のスクリプト共用ファイルに置いてはならない(#931)

    • lib/prisma.ts に import 'server-only' を追加すると、scripts/ から Prisma をインポートするスクリプトが GitHub Actions(Bun 直接実行)でクラッシュする。
    • server-only パッケージは Next.js が react-server エクスポート条件を設定した場合のみ no-op になる。Bun でスクリプトを直接実行すると同条件が設定されず、throw new Error(...) が実行される。Vitest も同様のため test/__mocks__/server-only.ts でモックが必要になる。
    • 正しい対策: クライアントコンポーネントへの Prisma 混入を防ぐには、Prisma を使う関数(サーバーサイドのみ)と純粋関数を別ファイルに分離する(例: lib/tiktok.ts と lib/tiktok-utils.ts)。クライアントコンポーネントは純粋関数のファイルのみをインポートすればよく、server-only は不要。
    • まとめ: server-only はスクリプト・ライブラリ層ではなく、Next.js のページ・レイアウト・Server Actions 等、Next.js バンドラーが必ず処理するファイルにのみ置く。
  9. ワークフロースクリプトのログレベル制御は lib/logger.ts で行う(#981)

    • scripts/ 配下の収集スクリプトは lib/logger.ts の logger を使い、console.log を直接書かないこと。
    • ログレベルは LOG_LEVEL 環境変数で制御する(デフォルト: INFO)。開発時のデバッグには LOG_LEVEL=TRACE を設定する。
    • レベル一覧(昇順): TRACE / DEBUG / INFO / WARN / ERROR
    • scripts/ ディレクトリは @/ エイリアス不可のため、インポートは相対パスで行う:
      import { logger } from '../lib/logger';
    • TRACE レベルはループ内の逐次処理(ツイート解析・DB 登録)の詳細追跡に使用する。INFO レベルは進捗サマリー(取得件数・登録件数)に使用する。
  10. TypeScript 6.0 への移行条件と手順(#443)

    • TypeScript 6.0 へのアップグレードは typescript-eslint の peerDependencies が <6.0.0 のまま対応されていなかったが、typescript-eslint@8.60.1 で >=4.8.4 <6.1.0 に拡張された。ただし eslint-config-next@16.2.1 が typescript-eslint@8.56.0 を固定しているため、現時点では 8.56.0 がインストールされ peerDependency 不整合が残る。bun はこの不整合を警告のみで処理するため機能上の問題はなく、全品質チェック(test・lint・type-check)通過済。
    • tsconfig.json やコードの変更は一切不要で、package.json の typescript を ^6.0.0 に変更して bun install するだけで完了した(型エラーなし、全テスト通過)。
    • @types/node のメジャーバージョンは実行環境(Node バージョン)に合わせること。Node 24 運用の場合は ^24.x。^25.x にすると Node 24 で存在しない API が型上は通ってしまうため注意。
    • 合わせ込み候補として jsdom のメジャーアップ(^28 → ^29)と engines フィールドの追加("node": ">=24")も同時実施。
  11. 手動実行の scripts/patch/*.ts は Blob 更新後にキャッシュを破棄しないと、ページによって反映タイミングがバラつく(#1286)

    • app/members/[id]/page.tsx は getMembersFromBlob()(lib/blob.ts)を直接呼んでおり、fetch(blobUrl, { next: { revalidate: 3600 } }) で 1時間 の Next.js Data Cache がかかる。一方 app/member-map/page.tsx はクライアント側で fetch('/api/members') を呼び、その Route Handler(app/api/members/route.ts)は fetch(blobUrl, { cache: "no-store" }) で キャッシュなし。同じ Blob データでも経路によってキャッシュ戦略が異なるため、パッチスクリプト実行直後に検証すると「片方の画面では反映済、もう片方では古いまま」という食い違いが起きる。
    • GitHub Actions から実行されるスクリプト(sync-members.yml 等)は実行後に curl -X POST $APP_URL/api/revalidate(Authorization: Bearer $REVALIDATE_TOKEN)でキャッシュを破棄しているが、scripts/patch/*.ts はローカルから手動実行するため、この仕組みに乗っておらず、どれもキャッシュ破棄を行っていなかった。
    • 対処: patch-member-nicknames.ts に、Blob・data/members.json への書き戻し完了後、APP_URL/REVALIDATE_TOKEN を使って /api/revalidate を呼ぶ処理を追加した(未設定ならBLOB_READ_WRITE_TOKEN等と同様に早期にエラーとする)。patch-member-colors.ts にも同じパターンで追加した(#1298)。ただしこちらは data/members.json への書き戻しは行わず、Blob 更新後の /api/revalidate 呼び出しのみを追加している(data/members.json の同期は別目的のためスコープ外とした)。
    • 横展開(#1295): 重複コードを避けるため revalidateCache を scripts/patch/revalidate-cache.ts に共通ユーティリティとして切り出し、patch-member-nicknames.ts・patch-member-colors.ts をこれに追従させた上で、残る手動パッチスクリプト(patch-leader-generations.ts・patch-instructor.ts・patch-member-joining-routes.ts)にも同じ仕組みを導入した。これで scripts/patch/*.ts 配下の Blob 書き戻しを伴うスクリプトは全てキャッシュ破棄まで一貫して行うようになった。
    • キャッシュ戦略の統一(#1296): app/api/members/route.ts の GET は PUT(保存時の読み込み→書き戻し。書き込み競合を避けるため意図的に no-store)の設定を流用していただけだったため、他のBlobアクセス経路と合わせて fetch(blobUrl, { next: { revalidate: 3600 } }) に変更した。PUT は読み込み→書き戻しの整合性維持のため no-store のまま維持している。
    • 追加調査(#1298): revalidatePath('/', 'layout') を呼んでも app/members/page.tsx(一覧画面)だけ更新が反映されないケースを確認した。app/members/[id]/page.tsx(詳細画面)は export const dynamic = 'force-dynamic' によりリクエストごとにサーバーで再実行されるため Data Cache 無効化がそのまま反映されるが、一覧画面は動的APIを使っていないため静的にプリレンダリングされ、revalidatePath 後も古い Full Route Cache が Preview 環境で残るケースがあった(ブラウザのハードリロードでも解消せず、サーバー側キャッシュが原因と特定)。対処: app/members/page.tsx にも export const dynamic = 'force-dynamic' を追加し、詳細画面と同じ挙動に揃えた。
  12. Neon の Import Data Assistant はプロジェクト作成と「Import」ブランチへの復元までしか行わない。Production への反映は別操作が必要(#1304)

    • Azure リージョン廃止に伴い Neon の Import Data Assistant で別プロジェクト(AWS リージョン)へ移行した際、ウィザードの完了後もターゲットプロジェクトの Production ブランチは空のままだった。
    • Import Data Assistant は「新しい Neon プロジェクトを作成し、指定した接続文字列のデータを Import ブランチへ復元する」ところまでが担当範囲で、Production ブランチへの反映(昇格)は自動では行われない。
    • 正しい手順: Import 完了後、対象プロジェクトの Production ブランチ → Backup & Restore → 「From another branch」タブ → ソースに Import ブランチを選択 → Restore を実行する。これは完全上書き(マージではない)だが、Neon 側が実行前の Production の状態を自動でバックアップブランチとして残すため元に戻せる。
    • 注意: この Restore を行う前に Import ブランチを削除すると、コピーしたデータごと失われる(移行元の DB 自体は読み取り専用のまま変更されないため、その場合は Import Data Assistant からやり直せば復旧は可能)。Restore で Production にデータが反映されたことを確認してから Import ブランチを削除すること。
  13. gh secret list は値を取得できないため、secrets → variables 移行時は値の入手経路を個別に確認する(#1351)

    • gh secret list / gh api repos/:owner/:repo/actions/secrets はどちらもシークレットの名前とメタデータのみを返し、値は API 経由で一切取得できない(GitHub の仕様)。移行先の vars.* に設定する値は、コード・ドキュメント・ローカル設定ファイルなど別の経路から確認する必要がある。
    • VERCEL_ORG_ID / VERCEL_PROJECT_ID はローカルの .vercel/project.json(.gitignore 対象・vercel link 実行時に生成)に平文で存在するため、ここから値を確認できる。
    • 値が不明な項目(本番URL・SMTPサーバー設定等)は、README の変更履歴やユーザーへの確認、公式ドキュメント(例: Gmail の SMTP は smtp.gmail.com / ポート 465(SSL)・587(STARTTLS))で裏付けを取ってから設定すること。推測で値を設定すると、対象のワークフロー(メール送信・デプロイ等)が次回実行まで気づかれずに壊れる。
    • gh variable list は(secrets と異なり)値も含めて出力されるため、移行後は gh variable list で設定値を直接確認できる。
  14. Vercel Blob の public URL は put() 直後でも CDN エッジキャッシュにより古い内容が返ることがある(#1419)

    • put() に allowOverwrite: true を指定して正常に書き戻しても、同じ public URL(例: https://xxxx.public.blob.vercel-storage.com/units.json)へ直後に curl 等で素朴にアクセスすると、CDN エッジが保持する更新前のレスポンスが返ることがある。これはアプリ側の Next.js Data Cache(fetch(..., { next: { revalidate } }))とは別レイヤーのキャッシュで、revalidateCache(/api/revalidate 呼び出し)を実行済でも影響を受ける。
    • 対処: パッチスクリプト実行後に反映結果を手動検証する際は、クエリパラメータでキャッシュバスティングする(例: ?t=$(date +%s))こと。素の URL への curl で古い内容が返っても、書き込み自体が失敗しているとは限らない。
  15. morning-status-app/docs から本リポジトリへのdocs反映は、ディレクトリ全体コピーではなく差分ファイル単位で行う必要がある(#1434)

    • 両リポジトリ間に自動同期の仕組み(サブモジュール・シンボリックリンク・同期スクリプト等)はなく、#1341のPoC時に手動コピーされたきりの状態
    • docs/design/common/specification.md は本リポジトリ側でPoC時に非互換フロントマター(layout: single / sidebar.nav)を意図的に削除済だったため、diff -rqで単純比較すると「差分あり」と表示されるが、本文(フロントマター以降)は実際にはmorning-status-app側と同一
    • この状態を機械的に上書きコピーすると、既知の非互換フロントマターを誤って再導入し、ビルド・表示を壊してしまう
    • 対処: diff -rqで差分ファイル一覧を洗い出した後、各ファイルの差分内容をdiffで個別に確認し、実質的な内容更新のみをコピー対象とする。frontmatter起因の意図的な差分はスキップする(specification.mdの是正自体はmorning-status-app側Issue #1438のスコープ)
  16. BlumeでMermaid図を描画するには.mdxへの変換が必須。プレーンな.mdではコードブロックのまま表示される(#1435)

    • node_modules/blume/src/markdown/index.tsのblumeMdxProcessor(mermaidPlugin()を含む)は@astrojs/mdx用プロセッサとして.mdxにのみ適用され、.mdはblumeMarkdownProcessor(mermaidPluginを含まない)が使われる。プレーンな.mdのまま ```mermaid ブロックを書いても、Mermaidの<blume-mermaid>要素へは変換されない
    • 対処: 図を含む設計書は.mdから.mdxへgit mvでリネームする。リネーム後は他ファイルからの内部リンク([テキスト](旧ファイル名.md))も全て.mdxに更新する必要がある。リンク更新時は(?<![\w-])旧ファイル名\.mdのような否定後読み正規表現を使い、他の長いファイル名の部分文字列(例: screen-design.mdがlive-screen-design.mdの末尾と一致してしまう)を誤って書き換えないよう注意する
  17. MDXは行内コード(バッククォート)で囲まれていない裸の<tag>・{式}をJSX/JS式として解釈し、ビルドを壊す(#1435)

    • .md→.mdx変換時、表内の改行に使う裸の<br>(自己終了していない)や、プレースホルダー表記の裸の{メンバー名}のような記法がプレーンテキスト中に残っていると、MDXコンパイラがJSX/JS式として解釈しようとしてビルドエラーになる
    • 対処: 該当箇所をバッククォートで囲みインラインコードにする(既存ドキュメントの大半はこの慣習に従っていた)。<br>のようなvoid要素は自己終了タグ<br />にする
    • コードフェンス(```)・インラインコード(`)で囲まれた範囲は安全(MDXはコード範囲内をJSX解釈しない)。変換前に対象ファイルをコードブロック除去した上で裸の<・{が残っていないか機械的にチェックすると見落としを防げる
  18. Blumeのmermaid.initialize()はuseMaxWidthを無効化していないため、幅の広いMermaid図はコンテナ幅に縮小表示され読みにくくなる(#1435、blume 1.3.1で大幅改善・#26)

    • node_modules/blume/src/components/content/mermaid-element.tsのmermaid.initialize({securityLevel: "strict", startOnLoad: false, theme: ...})にはflowchart: { useMaxWidth: false }等の指定がなく、Mermaid側のデフォルト(useMaxWidth: true)のままレンダリングされる
    • 横に広いflowchartをsubgraphでクラスタ分割しノード同士を疎结合にしても、クラスタ間に実エッジ(双方向的な関連)が残っていると、Mermaidのレイアウトエンジン(dagre)がクラスタを横並びに配置してしまい、根本的な解決にはならなかった(不可視エッジ~~~でのクラスタ間の縦積み強制も効果薄)
    • 真因はuseMaxWidth単体ではなく<blume-mermaid>ラッパーのflex縮小だった: blume 1.3.0(upstream #137)でnode_modules/blume/src/markdown/mermaid.tsのクラスが"...flex justify-center overflow-x-auto"から"...flex overflow-x-auto [&>div]:w-full [&>div>svg]:mx-auto [&>div>svg]:block"に変更された。修正前はSVGのwidth:100%が本質的な幅を持たないため、shrink-wrapされたflexアイテムがブラウザのreplaced-element既定値300pxに潰れ、そこにviewBoxごと縮小されていた(useMaxWidthの値に関わらず全図が実質300px相当になっていた)。修正後はchild divがw-fullでコンテンツ幅いっぱいに広がり、useMaxWidth: true(デフォルト)の図はプローズ幅(数百px〜)まで正しくスケールされるようになった
    • 本リポジトリは#26でblume 1.3.1に更新済(このFollow-up発生時点の1.2.0から2バージョン先)。上記修正を含む
    • bun patchによるuseMaxWidth: false強制は不要になった: 1.3.0以降、個別の図だけ自然サイズ+横スクロールにしたい場合は、Markdownソース側にMermaidのinitディレクティブ(%%{init: {'flowchart': {'useMaxWidth': false}}}%%)を書くだけで実現できる(ラッパーのoverflow-x-autoは維持されている)。全図一律のuseMaxWidth: falseパッチは横スクロール多発の副作用があるため、必要な図にだけ個別適用する方が望ましい
  19. Windows(core.autocrlf=true)で改行コードがCRLFのファイルに対し、Node.jsの(.+)$系の正規表現がマッチしないことがある(#1436)

    • このリポジトリのdocs/配下はgit blob上ではLFで保存されているが、core.autocrlf=trueのWindows環境ではローカルの作業ツリー上はCRLFとしてチェックアウトされる(git show HEAD:<file>で確認するとLF、file <file>で確認するとCRLFと表示される)
    • JavaScriptの正規表現で.(ドット)はデフォルトで改行文字(\n・\r・
・
)にマッチしない。content.split('\n')で行分割すると各行末に\rが残るため、/^#\s+(.+)$/のような「$で終端まで到達させたい」正規表現は、(.+)が\rを消費できず$にも到達できないためマッチ自体が失敗する(/^#\s+(.+)/のように$を外すか.+?にすると一見動くが、意図と異なる範囲でマッチする)
    • 対処: 行分割はcontent.split(/\r?\n/)のように\rごと区切り文字に含める。書き戻す際はoriginal.includes('\r\n')等で検出した元の改行コードでjoinし、無関係な改行コード変更によるdiffノイズを避ける
  20. frontmatterのtitleにMarkdownの見出しテキストをそのまま流用する場合、YAMLの安全性のため常時ダブルクォートで囲む(#1436)

    • 見出しテキストに: (半角コロン+半角スペース)が含まれる場合(例:「調査レポート: X投稿から…」)、YAMLの非クォート文字列としては「マッピングのキー: 値」の区切りと誤認され、パースエラーになる
    • 見出しに**太字**等のインライン装飾が含まれる場合、Blume本体の見出しテキスト抽出(ctx.textContent(node)、mdast-util-to-string相当)は装飾記号を含まないプレーンテキストを返すため、frontmatterへ転記する際も同様に装飾記号を除去してから使うこと(**text**をそのまま転記すると実際のderiveTitle()の出力と食い違う)
  21. Blumeは相対的な内部リンク(.md/.mdx拡張子付き)を自動でクリーンURLに解決しない。実際のルートは拡張子を除いた絶対パスで書く必要がある(#1437)

    • docs/design/foo.mdxから同じdocs/design/配下のbar.mdxへ[bar](bar.mdx)のような相対リンクを書いても、Blumeにはこれを/design/barのようなクリーンURLへ書き換えるremarkプラグインが存在しない(grepでresolveLink/rewriteLink相当の実装を探しても見つからない)
    • ページのルートは「ファイル名を除いた絶対パス」(例: docs/design/common/data-design.md → /design/common/data-design/)になるため、.md/.mdx拡張子付きの相対リンクをクリックすると、拡張子がそのままURLの一部として扱われ404になる(同一フォルダ内の兄弟ファイルへのリンクでも同様に404になる。「../を使った上位フォルダ越えの相対リンクだから」ではない)
    • 対処: 内部ドキュメントへのリンクは、相対パスではなく/design/<カテゴリ>/<ファイル名(拡張子なし)>のようなサイト絶対パスで書く。サイドバー等Blumeが自動生成するナビゲーションリンクは元々この形式で出力されており、正しく機能する
    • 静的サイト全体をPlaywrightで実際にクロールし、各ページのリンクをクリックして200/404を確認するのが、この種の「ビルドは通るが実際にクリックすると壊れる」リンク切れを検出する最も確実な方法(bun run buildだけでは検出できない)
  22. フォルダ単位でサイドバーのカテゴリ名・並び順を制御するにはmeta.tsを置く(#1437)

    • 各フォルダにmeta.ts(.js/.mjsも可)を置き、import { defineMeta } from "blume"; export default defineMeta({ title: "...", order: N })とすることで、そのフォルダのサイドバー表示名・並び順を指定できる
    • meta.tsを置かないフォルダは、フォルダ名をtitleCaseしたもの(例: design → Design)がデフォルトの表示名になる。日本語の表示名にしたいカテゴリには必ずmeta.tsを置くこと
    • meta.tsはビルド後のページ数・ルーティングには影響しない(純粋にサイドバー表示用のメタデータ)
  23. Markdownの見出しアンカーが正しいかは、目視でスラッグを推測せずgithub-sluggerを実際に実行して確認する(#1438)

    • Blumeの見出しID生成はgithub-sluggerパッケージをそのまま使っている(node_modules/blume/src/markdown/heading-anchors.ts)。## W1. メンバー近況同期バッチのような見出しは、.の除去・空白のハイフン化・小文字化を経てw1-メンバー近況同期バッチになるが、先頭のwを書き忘れる(#1-...)といった凡ミスは目視レビューで見逃しやすい
    • 検証方法: import GithubSlugger from "github-slugger"し、対象ファイルの全見出し行(^#{1,6}\s+)に対して同じGithubSluggerインスタンスで順に.slug()を呼ぶ(重複見出しは自動的に-1等の連番が付くため、単一見出しごとにnew GithubSlugger()し直すと連番判定を誤る)。これで得たスラッグ集合と、実際のリンクの#anchor部分を全ファイル横断で突合すれば、壊れているアンカーを機械的に洗い出せる
    • この方法はサイト全体のリンク切れ検出(#1437のuseMaxWidthとは別の観点)にも転用できる。ビルド(bun run build)が通ってもアンカー切れは検出されないため、别途この種の検証が必要
  24. Vercel Blobのput()はcacheControlMaxAge未指定だとCDNエッジに1ヶ月キャッシュされる。恒久対策はcacheControlMaxAge: 60(指定可能な最小値)の明示(#1442)

    • #14で記録した「put()直後でも CDN エッジキャッシュにより古い内容が返ることがある」現象の根本原因は、@vercel/blobのput()がcacheControlMaxAge未指定時にデフォルトで1ヶ月CDNエッジキャッシュする仕様だった(公式型定義コメントで確認)。allowOverwrite: trueで同一URLを使い回す書き込みでは、Next.js側でrevalidatePathを呼んでもBlobの公開URL自体が持つCDNエッジキャッシュまでは制御できない。
    • 対処: 頻繁に書き換わるBlobへのput()呼び出しにcacheControlMaxAge: 60を追加する。#14のキャッシュバスティングはあくまで検証時の回避策であり、恒久対策にはならない点に注意。
    • リポジトリ全体でput()呼び出しを洗い出したところ34箇所全てで未指定だった。毎日スケジュール実行・ユーザー操作で頻繁に呼ばれる箇所(sync-status.ts・daily-digest.ts・collect-instagram-posts.ts・app/api/members/route.ts、計5箇所)を本Issueで対応し、残り(手動実行専用ワークフロー・一回限りパッチスクリプト、29箇所)は#1443で追跡する。
  25. Electrobunデスクトップアプリに同梱されるBunバイナリは、システムにインストールしたBunとは独立した固定バージョン。上げるにはelectrobun.config.tsのbuild.bunVersion指定が必要(#1494)

    • electrobunパッケージ(v1.18.1時点)はdist-win-x64/bun.exeとして自前で固定バージョンのBunバイナリ(node_modules/electrobun/dist/api/shared/bun-version.tsのBUN_VERSION定数、確認時点で1.3.13)を同梱しており、bunx electrobun buildはデフォルトでこれをそのままアプリのbin/bun.exeにコピーする。ローカル環境のbun upgradeでシステムのBunを更新しても、この同梱バイナリには一切反映されない
    • Next.js 16(Turbopackビルド)の standalone サーバーを Bun 1.3.10〜1.3.13 で実行すると TypeError: Expected CommonJS module to have a function wrapper(Bun側の既知バグ、oven-sh/bun#25609、1.3.14で修正)が発生し起動できない。この現象を「システムのBunをアップグレードすれば直る」と誤認しやすい
    • 対処: desktop/electrobun.config.tsのbuildにbunVersion: "1.3.14"のように明示指定する。指定するとビルド時にGitHub Releases(https://github.com/oven-sh/bun/releases/download/bun-v<version>/bun-windows-x64-baseline.zip等)から該当バージョンをダウンロードしてキャッシュし、同梱バイナリとして使用する(ensureBunBinary/downloadCustomBun、node_modules/electrobun/src/cli/index.ts参照)
    • 同梱バイナリの実バージョンは、ビルド後に desktop/build/<target>/<AppName>/bin/bun.exe --version で確認できる
  26. git pullで大量ファイルを一括fast-forwardした直後、続けてgit checkout -bすると.git/logs/HEADへの書き込みがPermission deniedで失敗することがある(Windows環境、#1501)

    • git checkout main && git pull && git checkout -b <branch>を連続実行した際、git pull自体は正常に完了(fast-forwardでコミットも反映済)しているにもかかわらず、直後のgit checkout -bでfatal: unable to update HEADが発生した。原因は.git/logs/HEADへの追記がPermission deniedで拒否されたことで、Windows環境でウイルス対策ソフト等が直前の大量ファイル書き込み(大きめのfast-forward)を受けて.git配下を一時的にスキャン・ロックしていることが疑われる
    • git statusで確認すると、pullは完了しmainブランチの状態は正常(作業ツリーもクリーン)。ブランチ作成コマンド自体は「ref自体は作成されたがHEAD切り替えだけ失敗」という中途半端な状態になっており、git checkout -bを再実行するとfatal: a branch named '<branch>' already existsになる
    • 対策: パニックにならずgit statusで実際の状態を確認する。ref作成は成功しているため、git checkout -bの再実行ではなくgit checkout <branch>(-bなし)で切り替えれば復旧する。同一セッション内で2つの独立したリポジトリ(本リポジトリとmorning-status-app)で同じ現象が再現したため、単発の偶発事象ではなく大量pull直後に起きやすい傾向と考えられる
  27. ElectrobunのbunVersionを上げる際は、同梱バイナリでNext.js standaloneサーバーを直接起動して検証すると、パッケージ済.appのランチャー起動フローの不具合と切り分けられる(#1534)

    • bunVersion: "1.4.0"([25]参照)へのバンプ自体はbunx electrobun buildで正常にダウンロード・同梱された(Custom Bun 1.4.0 for macos-arm64 set up successfully)。この時点では同梱Bun本体の互換性は未検証
    • 同梱バイナリ(macOSでは{app}.app/Contents/MacOS/bun)から直接server.jsを起動しcurlで疎通確認する方法で、Bunバージョン自体の互換性([25]のTypeError: Expected CommonJS module to have a function wrapper系リグレッションの有無)をランチャーのパス解決ロジックから独立して検証できる
      cd {app}.app/Contents/Resources/nextjs/standalone
      DESKTOP_MODE=1 HOSTNAME=localhost PORT=3099 {app}.app/Contents/MacOS/bun server.js
      curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3099
    • この検証中、desktop/src/bun/index.tsのパス解決({app}/bin/bun(.exe)前提)がmacOSの.appバンドル構成(Contents/MacOS/・Contents/Resources/が同階層)と噛み合わず、ランチャー経由の起動がENOENTで失敗する既存バグを発見した(Bunバージョンとは無関係、Windowsは影響なしと推定、morning-status-app#1535で追跡)。上記の直接起動検証を先に行っていなければ「Bun 1.4.0で壊れた」と誤診しかねないところだった
  28. MDXファイル内にHTMLコメント(<!-- -->)を書くとビルドが壊れる。JSX式コメント({/* */})を使う(morning-status-app#1546)

    • !はJSX/JSXタグ名として解釈できない文字のため、<!-- text -->をMDX内に書くとMDXError: Unexpected character ! ... (note: to create a comment in MDX, use {/* text */})でビルドが失敗する(プレーンな.mdでは問題にならないが.mdxは不可)
    • 欠番になったセクション番号(例: バッチ設計書でW番号を統合・欠番化した際の「W15はここに統合済」的な注記)をコメントとして残したい場合は{/* W15は#1546でW5に統合したため欠番 */}のように書く
  29. blume buildはblume devサーバー起動中に実行するとエラーになる。--isolatedフラグで.blume-verify配下に隔離ビルドできる(morning-status-app#1546)

    • bun run dev(blume dev)を裏で動かしたままbun run buildを実行すると、「A blume dev server is running… building would corrupt its .blume runtime」で失敗する
    • bun run build -- --isolated(blume build --isolated)を使うと、.blume-verify/配下に隔離してビルド検証でき、稼働中のdevサーバーの.blumeランタイムを壊さない。実装フローのビルド確認(設計書更新時の必須チェック)ではこちらを使う
    • .blume-verify/はビルドツールが自動で.gitignoreに追記する(コミットに含めてよい)
  30. CIのtestジョブはdesktop/の依存関係もHutch devkitも用意していないため、desktop/src配下にVitestテストを追加すると必ず落ちる(morning-status-app#1548)

    • Electrobun 2.0.1(Hutch方式)でdesktop/tsconfig.jsonが./.hutch/devkit/tsconfig.jsonをextendsするようになった。desktop/.hutch/は.gitignore対象で、bunx electrobun dev/build/prepareを実行したときにのみローカルに生成される(bun installだけでは生成されない)
    • .github/workflows/deploy-vercel.ymlのtestジョブは元々ルートのbun installとbun run testしか実行しておらず、desktop/の依存インストールもelectrobunコマンドの実行も一切していなかった。CI(および.hutch未生成の新規clone環境)でVitestがdesktop/src/bun/*.test.tsを変換しようとすると、tsconfigのextends解決先が存在せず[TSCONFIG_ERROR] Tsconfig not foundで落ちる
    • 落ちたテストファイル自体はelectrobunパッケージ由来のimportを使っておらず、devkitの中身(パスエイリアス)を実際には必要としない。とはいえ空のtsconfigスタブをでっち上げて誤魔化すのは正攻法ではない——desktop/src/bun/index.tsはimport { app, BrowserWindow } from "electrobun/bun"を実際に使っており、これはelectrobunパッケージのnode_modules実体(lib/moved.cjs)がわざと例外を投げるスタブになっていて、tsconfigのpaths経由でしか解決できない設計(Bunのランタイムもtsconfigのpaths/baseUrlを見てモジュール解決する)。つまりextends自体は本物のアプリコードに必須で、消してよい記述ではない
    • 対応: testジョブにbun install(working-directory: desktop)とbunx electrobun prepare --env=stable(同)をbun run testの前に追加し、Hutch純正コマンドで.hutch/devkitを実体化させてから既存のテストコードをそのまま実行する
    • この「gitignore対象の生成物にtsconfigが依存しており、生成コマンドを一度も実行していない環境でのみ失敗する」という構造は、note #136(tsconfig.scripts.jsonのnext-env.d.ts依存、MorningStatusApp#1629)でも再発している。2件を横断した一般化はnote #136を参照
    • Electrobun導入者向けの実際の要件としては、この発生記録ではなくデスクトップアプリ(Electrobun)設計書 §10に明文化した(MorningStatusApp#1629の調査を機に追加)。本noteは発見の経緯を残す記録として残置する
  31. バッチ設計書の「実行タイミング」表はcron変更のたびに更新漏れが起きやすい。cron式を変更する際は必ず設計書の対応する行も突き合わせて確認する(morning-status-app#1566)

    • #1546(収集系ワークフローの実行時刻3時間後ろ倒し)でW1・W4・W7の実行タイミング記載の乖離を是正したが、同じ変更対象だったW14(デイリーダイジェスト配信)の表だけ更新が漏れ、実際のcron(0 16 * * * = JST翌1:00)と設計書の記載(旧時刻のままの23:00)が乖離した状態が約1週間残っていた
    • cron式は.github/workflows/*.ymlのコメント(UTC/JST表記)と設計書の表の二重管理になっており、どちらか一方だけ変更してもレビューでは気づきにくい(yml側のコメントは正しく更新されていたため、コードレビューだけでは設計書側の乖離が検出されなかった)
    • 対策: 実行時刻に関わるIssue対応時は、対象ワークフローだけでなく同じ設計書内の他バッチの記載もgrepで横断的に突き合わせ、意図しない乖離が残っていないか確認する
  32. scripts/を型チェック対象に含める際、既存のtsconfig.json(app向け)にマージせず別ファイル(tsconfig.scripts.json)にする(@types/bunのグローバル型がapp側に漏れるのを防ぐため、morning-status-app#1572)

    • 検討当初は「tsconfig.jsonに@types/bunを追加しscriptsをexcludeから外すだけで済むのでは」という1本化案があったが、実際に試すとapp/link-editor/LinkEditorView.test.tsx・components/MemberDetailView.test.tsxなどapp側の既存テストが新規に壊れた。原因は@types/bunのfetch型定義(preconnectメソッド等を含む独自シグネチャ)が、Vitestのglobal.fetch = vi.fn()という直接代入パターンと衝突するため。scripts側とapp側でグローバル型の要求が異なる以上、tsconfigを分離するしかない
    • scripts/から import されるlib/*.ts(app側と共用のファイル)をtsconfig.scripts.jsonで正しく型チェックするには、paths: { "@/*": ["./*"] }とnext-env.d.tsのincludeが必要(lib/*.ts自身は@/エイリアスを使い、かつNext.jsのfetch拡張オプションnext: {...}の型はNext.js本体の型宣言(next-env.d.ts経由)に依存しているため)
    • libオプションにdomを含めるとResponse.json()がPromise<any>型になり、無検証JSON代入(unknownのまま構造化データとして扱う)を検出できなくなる。domを外しtypes: ["bun"]のみにすると、@types/bun自身がfetchのnextオプション拡張とResponse.json(): Promise<unknown>の両方を正しく型付けするため、domは不要(検出力を落とさずに済む)
    • 検証方法: .claude/tmp/等の作業ディレクトリに置いた使い捨てtsconfigでbunx tsc --noEmit -p <config>を試し、設定変更ごとにエラー件数・対象ファイルの差分を確認してから本採用する。本物のソースツリーに検証用ファイルを置く必要はなく、include配列に一時パスを追加すれば足りる
    • scripts/配下のテストでglobal.fetch = vi.fn()のような直接代入パターンを使っていると、@types/bun導入後に同種のエラーが起きる。確立された回避策はvi.stubGlobal('fetch', vi.fn())(unknown引数を取るため型が衝突しない)で統一すること
    • 備考(訂正、MorningStatusApp#1629): 上記「@types/bun自身がfetchのnextオプション拡張を正しく型付けする」という記載は誤りだった。実際に型付けしているのは@types/bunではなく、includeに指定したnext-env.d.tsがnextパッケージのnode_modules/next/types/global.d.ts(interface RequestInit { next?: NextFetchRequestConfig }のグローバル宣言マージ)を読み込んでいたため。next-env.d.tsはnext dev/next build実行時にのみ生成される.gitignore対象ファイルで、新規clone直後(一度もnext build/next devを実行していない環境)には存在せず、includeに書いても実体がなければ何も読み込まれない。当時の検証はこのファイルが既に生成済の環境で行われたため、依存に気づけなかった。対策: next-env.d.tsの存在に依存せず、scripts/global.d.tsに同一のグローバル型拡張を直接記述してscripts側で完結させた(nextパッケージ全体をtypesに加える方式は他のグローバル宣言まで巻き込むため採用しなかった)
  33. blume devはURLの末尾スラッシュ有無でルーティング結果が変わる。ビルド後の静的出力(bun run build)は両方とも200になるため、blume devだけで再現するルーティング不具合に見える(#107)

    • blume dev起動中に/notes/SITE_CONFIGURATION/(末尾スラッシュあり)へアクセスすると404になるが、/notes/SITE_CONFIGURATION(末尾スラッシュなし)は200になる。bun run buildの出力は/notes/SITE_CONFIGURATION/index.htmlという構成で、静的ホスティングでは通常どちらの形式でもアクセスできるため、これは開発サーバー固有の挙動
    • Playwright等でdevサーバーの画面を手動検証する際、既存の内部リンク表記(末尾スラッシュあり)をそのままURLに使うと「ページが存在しない」ように見えるが、実際のページ・ビルド出力には問題がない。末尾スラッシュを外したURLで再検証すること
    • blumeバージョン更新(1.5.1→1.6.6)と同時に気づいたが、バージョン起因のリグレッションかは未確認(要検証時は旧バージョンとの比較が必要)。少なくとも1.6.6ではこの挙動が存在する
  34. 設計書のセクション番号(例: ### 2-7.)に新しいテーブル・項目を追加する際、論理的に近い位置へ差し込むと既存セクションの番号がズレ、他ドキュメントからのアンカーリンクが壊れる。新しいセクションは常に末尾に追加する(live-data-design.mdx、morning-status-blume#120)

    • live-data-design.mdxはvenues→tours→lives→member_lives→prefecture_codes→members→festivals→setlists→setlist_tracksの順で並ぶ。festivalsは内容的にはlivesの近くに置く方が読みやすいが、後から追加された際に既存番号を保持するため末尾寄りに配置されている。member_festivals(メンバー×フェス出演紐づけ)を追加した際も、内容的に対になるmember_lives(§2-4)の直後ではなく、既存最終セクションsetlist_tracks(§2-9)の後に§2-10として追加した
    • Blumeの見出しアンカーは見出しテキストからスラッグ生成される(note 27参照)ため、セクション番号を含む見出し文言が変わるとアンカーも変わり、他ページやIssueコメントからのリンクが切れる
  35. Blumeを含むパッケージの更新後は、package.jsonの変更を取り込んだ各環境でbun installを実行し、bun run blume --versionで実際に動く版を確認する。古い版が動いていると、新しい版で追加された設定キーがBLUME_CONFIG_INVALID(Unrecognized key)になる場合がある(Blume 1.7.0、#123)

    • 症状: 1.6.6→1.7.0の更新はmacOSでbun run build・bun run doctorの通過を確認してマージした(#127)。その後Windowsでビルドしたところ、blume.config.tsのsearch.indexing.includeCodeBlocks(1.6.0で追加)がUnrecognized keyで拒否され、bun run buildが失敗した。同じ設定でmacOSは通ったため、当初は1.7.0の回帰(OS差異)を疑い、上流(haydenbleasel/blume#273)に報告して1.6.6へ完全固定した。.github/workflows/が存在せず自動チェックが無いため、Windowsで実行するまで検知できなかった
    • 結論(訂正): 上流のメンテナのWindows clean runnerでも、こちらのWindows機でも、1.7.0で同じ設定のビルドが成功し、再現しなかった。上流は、エラー文言とキー欠落の組み合わせが1.5.x系のスキーマと一致することから、古い版(1.5.1)が動いた可能性が最も高いと判断し、not reproducibleでクローズした。こちらのWindows機のbunキャッシュには、1.5.1・1.6.5が2026-09-13に、1.6.6・1.7.0が2026-09-18に初めて入っており、それ以前のnode_modulesが古い版だった可能性がある。当時の環境は残っていないため断定はできないが、1.7.0の回帰ではなく環境の問題だった可能性が高い。当初のOS差異起因の回帰という見立ては撤回した
    • 教訓: git pullでpackage.json・bun.lockが変わったら、ビルドの前にbun installを実行し、bun run blume --versionで実際に動く版を確認する(bunx prismaがnode_modulesの旧版で動く、実装カテゴリのnote 98と同じ構造)。新しい設定キーがUnrecognized keyで拒否されたときは、回帰を疑う前に、bun pm ls・node_modules/.bin・bunキャッシュに旧版が残っていないかを先に確認する
    • 再発した場合: 直す前にnode_modules・bun.lock・bunキャッシュの状態を控え、上流(haydenbleasel/blume#273、クローズ済のため再オープン)に報告する。これは上流のメンテナからの依頼でもある
    • 特定の版に戻す場合の注意: package.jsonのキャレット範囲(例: "blume": "^1.6.6")のままでは、戻したい版より新しい版にも一致するためbun installしても戻らない。"blume": "1.6.6"のように完全固定する(#123では1.7.0の回帰を疑って一時的に固定したが、環境の問題と判明したため^1.7.0に戻した)
    • 切り分けのコツ: git checkout <更新前コミット> -- package.json bun.lockで旧バージョンに戻してbun run buildが通るかを確認する。ただしgit checkout -- <path>は(コミット指定なしでは)インデックスから復元するため、上記で一度ステージされた内容には戻らず、作業ツリーを元に戻すにはgit checkout HEAD -- <path>を使う
  36. UNITS_BLOB_URLのような静的Blob URLをGitHub Actions Repository secretsに登録する方式は、Blob側の実際のURLとシークレットの値がズレたまま気づかれないことがある。処理が異常に早く終わる場合はまずシークレットの値そのものを疑う(morning-status-app、2026-09-13)

    • 症状: sync-discography.tsのsyncDiscography()が実行開始直後(18〜24秒)に「units.json に MBID を持つユニットが見つかりませんでした。処理を中断します。」で中断していた。manual-releases.jsonに手動リリースを追加してワークフローを手動実行しても、原因はこの早期リターンでありReleaseテーブルには何も反映されていなかった
    • getUnitsFromBlob()(lib/blob.ts)はfetch失敗・レスポンスが配列でない・例外のいずれも黙って空配列[]を返すフェイルセーフ設計のため、ワークフローのログには「Blob URLの値が間違っている」ことが直接出てこない(mbid件数0件としか分からない)
    • 切り分け手順: ①ローカルの.env.localの同名変数で直接fetchして正常性を確認 → ②@vercel/blobのlist({ prefix: "units.json" })で現在の正規URLを取得し.env.localの値と一致するか確認(一致すればローカルの値が正しいと判断できる) → ③GitHub ActionsのRepository secretをその値で更新して再実行
    • put()にaddRandomSuffix: falseを指定していればBlobを再アップロードしてもURL自体は変わらない設計だが、それでもシークレットとBlob側の値がズレることがあった(原因は未特定)。「URLは変わらないはずだからシークレットの再同期は不要」と過信しないこと
  37. 新しい機能・画面の設計時は、データ取得層の適用判定(Server Componentの直接取得か、Route Handler(BFF)+ TanStack Queryか)を必ず行い、判定ログに追記する。判定ログが設計書の末尾にあると見落とされる(morning-status-blume#138、MorningStatusApp#1632)

    • 症状: 世代マトリックスの設計書(#128)では、データ取得層の適用基準(データ設計書(全体概要)§4)に沿った判定をしておらず、判定ログを持つ統合フィードアイテム データ設計書にも加筆がなかった。実装を進めた後に、BFF化が進んでいる中での取得方式を問われて初めて判定漏れが判明した
    • 原因: 統合フィードアイテム データ設計書が「SNS先行導入の設計(§1〜§5)+SNS以外への展開ログ(§6)」の二段構成になっており、新機能の設計者にはSNS専用の設計書に見え、末尾にある判定ログの存在と「新機能ごとに追記する」という運用に気付きにくかった
    • 対策: 構成を「適用判定ログ → 共通実装パターン → SNS新着投稿の個別設計」に再編し、判定ログを入口に置いた。あわせて概要に「新機能の設計時は、適用しない場合も判定を追記する」と明記し、データ設計書(全体概要)§4にも同じ旨を追記した
    • 教訓: 「追記しなければならない記録」を、特定ドメインの先行導入の設計書の末尾(展開ログ)に置くと、運用ルールが読み手に伝わらない。運用ルールを伴う記録は、入口となる概要・冒頭に置き、ルール自体も本文に書くこと
  38. §N参照のリンクをセクション直リンク化する際は、リンクテキストの§Nをそのまま信用せず、参照先の実際の内容と突き合わせて確認すること([#custom-id]アンカー導入、morning-status-blume#108)

    • blume check/blume doctorはリンク先の#idが実在するかは検証するが、リンクテキストに書かれた§Nという文言が実際にその内容を指しているかまでは検証しない。そのため、見出し番号の挿入・欠番化(note 34参照)で参照元の§N表記だけが古いまま取り残されても、ビルド・doctorのいずれでも検出されない
    • 実例: screen-design.mdxの「関連ライブ」行がlive-screen-design.mdx §8.1を参照していたが、該当内容(RelatedLivesSection)は実際には§12.1のサブセクションに移動済だった。番号だけでなく参照先見出しの本文を読んで対象を特定し、ズレていれば参照元の文言も合わせて修正すること
    • アンカーIDの命名規則: 見出しに固有の番号(### 5-5.・### 6.1等)がある場合はその番号をそのまま[#5-5]・[#6.1]のようにIDへ使う(既存の§N表記と一致するため、著者が対応関係を覚えやすい)。番号を持たないサブ見出し(コンポーネント名にのみ対応する見出し等)は、コンポーネント名由来の記述的なID(例: [#related-lives-section])を使う
    • 対象選定: リポジトリ内の§N言及は大半(500件超)が同一ドキュメント内の平文言及(リンクなし)で、そもそも「クリックしても飛ばない」問題の対象外。実際にMarkdownリンク化されている§N参照(grep -rnP '\[[^\]]*§[0-9][^\]]*\]\([^)]*\)' docs/で抽出)だけが今回の直リンク化の対象
  39. Blob上のreleases.json(RELEASES_BLOB_URL)はNeon移行(MorningStatusApp#854)時点で更新が止まったスナップショット。新しい処理の参照元にしない(MorningStatusApp#1618)

    • YouTube Shorts廃止(MorningStatusApp#943)で削除したリンク種別youtube-shortが残っており、現在のリリーススキーマ(ReleaseSchema)の検証に通らない。getReleasesFromBlob()で読むと例外になる
    • プレイリスト関連の2スクリプト(sync-playlist-track-ids.ts・build-playlist-index.ts)は今もここを参照している。対応はMorningStatusApp#1677(プレイリスト機能の再構築)
    • #36のシークレットのズレを検知する仕組みとしてW16(Blob URLシークレット診断バッチ)を追加した。RELEASES_BLOB_URLは上記の理由で対象外
  40. Blumeのメジャーバージョン更新は、手作業で差分を洗い出す前にnpx blume@latest upgradeを実行する。破壊的変更をファイル・行・置換内容つきで列挙してくれる(1.x→2.0移行、#154)

    • bunx blume@<新バージョン> upgrade --no-install(インストールを保留したい場合)または--claude/--codex(変更適用までコーディングエージェントに任せる場合)で実行する。package.jsonのblumeをバンプした上で、現在のblume.config.ts・components.tsを新バージョンのスキーマで検証し、BLUME_CONFIG_INVALIDとなる箇所をat blume.config.ts:7:5のように行番号付きで提示する
    • 1.x→2.0では、検索・デプロイ・コンテンツソース等の設定が「名前付き文字列/キー付きブロック」から「blume/*サブパスからimportするアダプター関数」に変わった(例: search.provider: "orama" → search: { provider: orama() }、deployment: { adapter: "vercel", output: "server" } → deployment: vercel()、ai.mcp → agents.mcp)。この種の設計変更はアップグレードガイド(node_modules/blume/docs/03-upgrading.mdx、/docs/upgrading)に網羅されているが、upgradeコマンドが自分のプロジェクトの設定に絞って該当箇所だけ提示してくれるため、ガイド全体を読むより早く着手できる
    • 移行後は、設定キーが変わっても既存の外部連携(本サイトではMCPのsearch_docs・日本語検索)が同じ挙動を保っているか、bun run build・bun run doctorに加えてblume devを実際に起動して確認すること。ビルド・doctorはスキーマの妥当性しか見ないため、ランタイムの挙動(検索結果が空でないか等)はここでは検出できない

実装

  1. Member Data Management (メンバーデータ管理)

    • 全歴代メンバーの情報を data/members.json に集約。
    • id はURLセーフなローマ字表記(例: nakazawa-yuko)を使用。
    • status は Active(現役)または OG(卒業生)で管理。卒業(OG)は不可逆であり、一度 OG になったメンバーを Active に戻してはならない。スクリプトから status を更新する際は updateMemberStatus() ガード関数を使用すること。
    • generation フィールドを追加し、期順でのソートやフィルタリングを容易に。
    • 日付フォーマットは YYYY-MM-DD に統一。卒業予定や未定の場合は null または特定の日付、ハイフン等で対応。
    • squash merge 時のデータ消失リスク: data/members.json のような大きな JSON ファイルを含む PR を squash merge すると、ブランチ内の中間コミット(データ修正 fix など)がコンフリクト解消時に失われることがある。PR マージ前に git show <squash-commit>:data/members.json で対象メンバーの値を必ず確認すること(過去に羽賀・横山・北川・nakazawa-yuko で発生)。
  2. JSON Data Handling (JSONデータの扱い)

    • メンバー数が多いため、IDの重複や構文エラーに注意。
    • latestStatus には、2026年2月時点の最新情報を反映。今後の自動更新エンジンのベースとなる。
    • statusHistory は省略可能(?)にし、既存データへの後方互換を保つ。初期化時は bun スクリプトで statusHistory: [] を全メンバーに一括追加する(JSONを直接編集するより安全で速い)。
  3. Runtime Data Defense (実行時データ防御)

    • TypeScript の型定義で string[](非 optional)としていても、古い JSON データや外部入力では undefined になる場合がある。配列操作(.map(), .filter(), スプレッド展開)の前に ?? [] フォールバックを付与すること(例: (member.latestStatus.sources ?? []).map(...))。
    • テストでは undefined as unknown as string[] 型アサーションで実行時の不正データを再現し、クラッシュしないことを検証する。
    • 外部 JSON のオブジェクト型ガードには Array.isArray() も組み合わせること: typeof [] === 'object' が true になるため、typeof data !== "object" || data === null だけでは配列を弾けない。オブジェクトのみを受け付けるバリデーションには Array.isArray(data) の否定チェックも必要(#238):
      if (typeof data !== "object" || data === null || Array.isArray(data)) {
        throw new Error("レスポンスがオブジェクトではありません");
      }
  4. JST ローカル日付の取得と Vitest のタイムゾーン設定

    • new Date().toISOString().split('T')[0] は常に UTC 日付を返すため、JST(UTC+9)の深夜0〜8時台に操作すると前日の日付が記録されるバグの原因になる。
    • 対策: new Intl.DateTimeFormat('sv', { timeZone: 'Asia/Tokyo' }).format(new Date()) を使うこと。ロケール sv(スウェーデン語)が YYYY-MM-DD 形式を返し、timeZone 指定により実行環境の TZ 設定に依存せず常に JST 日付が得られる(#63)。
    • 注意: new Date().toLocaleDateString('sv') でも同様の形式を返すが、実行環境のシステムタイムゾーンに依存する。GitHub Actions の ubuntu-latest(UTC)上で UTC 15:00〜23:59 に手動実行すると JST の「昨日」が記録されるリスクがある。
    • テスト環境のタイムゾーン設定: vitest.config.ts の test.env.TZ に 'Asia/Tokyo' を設定すると、テスト実行時の Node.js タイムゾーンが JST になり、JST 境界値テストが可能になる。
      // vitest.config.ts
      test: {
        env: {
          TZ: 'Asia/Tokyo'
        }
      }
    • JST 境界値テストパターン(UTC 前日 15:30 = JST 当日 00:30):
      vi.setSystemTime(new Date('2026-02-22T15:30:00.000Z')); // JST では 2026-02-23
      // expect lastUpdated to be '2026-02-23'
    • サーバーサイドのタイムスタンプ生成テスト: vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime() を使うと、サーバー側コードの new Intl.DateTimeFormat('sv', { timeZone: 'Asia/Tokyo' }).format(new Date()) も偽装した日時を返す。API Route(route.ts 等)のタイムスタンプ生成が正しく JST 日付を生成することを、クライアントから送られた日付と区別して検証できる(#141)。
    • JST 日付グルーピングテストのタイムスタンプ選定(#475): UTC タイムスタンプをテストデータに使う場合、UTC 20:00:00Z 以降は JST で「翌日」になる(例: 2026-03-28T20:00:00Z = JST 2026-03-29T05:00:00)。JST で「同日」の投稿として期待するテストでは、UTC 00:00:00Z〜14:59:59Z の範囲(JST 09:00:00〜23:59:59)のタイムスタンプを使うこと。
  5. Tavily include_domains による公式SNS優先検索

    • performSearch に includeDomains?: string[] を追加すると、Tavily の include_domains パラメータとして渡せる。
    • URL から new URL(url).hostname でドメインを抽出し、不正なURLは try/catch で空配列にフォールバックする。
    • テストで include_domains が含まれていないことを確認する場合は expect(body).not.toHaveProperty('include_domains') を使うとシンプル。
    • 空配列([])の場合は include_domains をリクエストボディに含めないこと。Tavily の仕様上、空配列を渡すと意図しない挙動になる可能性がある。
  6. 日本語コンテンツの検出パターン

    • 外部API(Tavily等)の検索結果が英語の場合、日本語キーワードを追加したクエリ(例: ${name} 最新情報 日本語)で再検索することで日本語コンテンツを取得できる場合がある。
    • 再検索でも日本語が取得できない場合は空結果を返してスキップするのが適切(英語コンテンツを蓄積しない)。
    • 言語判定の実装方針(#188): ひらがな文字(U+3040-U+309F)の出現率で日本語文章かどうかを判定する。
      • ひらがなは日本語固有の文字であるため、英文中のカタカナ語や漢字交じりの中文(中国語)を正しく除外できる。
      • 実装: (content.match(/[\u3040-\u309f]/g) ?? []).length / content.length >= 0.08
      • 閾値 8% の根拠: カタカナ・漢字が多い日本語テキスト(例: 「タレント活動を継続中。」ひらがな率 ≈ 9%)も正しく検出できるギリギリの値。英文・中文は 0% なので余裕を持って除外できる。
      • テストで検証すべきケース: ひらがなのみ、カタカナ+ひらがな混在、漢字のみ(false)、英文+カタカナ(false)、中文(false)。
  7. sync-status.ts のドライランと副作用の制御

    • performSearch は SEARCH_API_KEY 未設定時に即座に空結果({ content: "", sources: [] })を返す(実際の API 呼び出しは行わない)。
    • fetchMemberStatus で SNS 検索結果が空の場合に officialSns[n].active = false のような副作用を発生させる場合、process.env.SEARCH_API_KEY が設定されているかを確認してから実行する必要がある。設定されていないドライランで副作用を発生させると、実際には API を呼んでいないのにデータが書き換わるバグになる。
      // 正しいパターン(#185: ! 不要、#186: console.warn もブロック内へ)
      if (process.env.SEARCH_API_KEY) {
        member.officialSns.forEach((entry) => {
          if (entry.active) entry.active = false;
        });
        console.warn(`  → SNS限定検索でコンテンツを取得できませんでした: ...`);
      }
    • テストでは「SEARCH_API_KEY 未設定時に副作用(active の変更)が発生しないこと」も検証すること。
  8. React イベントハンドラの型: React.FormEvent は非推奨 → React.SyntheticEvent と e.currentTarget を使う

    • onInvalid / onInput のハンドラ引数型として React.FormEvent<T> を使うと TypeScript が非推奨警告を出す。
    • 代わりに React.SyntheticEvent<T> を使うと解消できる。さらに e.target のキャストも不要になる e.currentTarget を利用するとより安全:
      const makeValidationHandlers = (message: string) => ({
        onInvalid: (e: React.SyntheticEvent<HTMLInputElement>) => {
          e.currentTarget.setCustomValidity(message);   // キャスト不要
        },
        onInput: (e: React.SyntheticEvent<HTMLInputElement>) => {
          e.currentTarget.setCustomValidity('');
        },
      });
    • e.currentTarget はハンドラが付与された要素自身を指すため、(e.target as HTMLInputElement) のキャストが不要になる(#213)。
  9. SNS判定は「ホスト一致」だけでは不十分(#223)

    • ameblo.jp や x.com のような共有ドメインでは、hostname だけで公式SNS判定すると他アカウントの投稿を誤って「公式更新」と判定してしまう。
    • 対策: hostname に加えて「アカウント識別子(通常はパス先頭セグメント)」まで一致させる。 例: https://ameblo.jp/member-a/... は member-a と member-b を区別する。
    • twitter.com と x.com のようなドメイン移行はホスト正規化テーブルで吸収する。
    • 判定ロジックは lib/sns.ts に集約し、UI(MemberCard)とバッチ(sync-status.ts)で共通利用することで仕様ズレを防ぐ。
  10. バッチ実行結果の可観測性をデータに持たせる

    • 毎日実行されるジョブでは「今の表示がいつの判定か」が最重要になるため、lastCheckedAt を必須に近い扱いで保存する。
    • 画面では以下を最低限表示すると運用確認が速い:
      • 一覧: 最終バッチ実行時刻 + 成功/エラー/未確認件数
      • 詳細: 状態、前回確認日時、判定理由(reason)
    • reason は API/スクレイピング失敗原因(例: timeout, 404)を短文で残し、一次調査をUIから開始できるようにする。
  11. snsCheck 導入時の後方互換戦略

    • 既存Blobデータに snsCheck がない期間を考慮し、UI側でフォールバック導出(resolveSnsCheck)を用意すると段階移行しやすい。
    • ただしフォールバックは暫定ロジックのため、最終的にはバッチで全メンバーに snsCheck を付与した状態に揃える。
    • 新規型追加時は types/member.ts と関連テスト(UI・バッチ・ヘルパー)を同時更新し、型安全と挙動の一致を維持する。
  12. 部分日付(YYYY-MM / YYYY)の範囲比較パターン(#246)

    • YYYY-MM-DD / YYYY-MM / YYYY / 空文字が混在する日付フィールドを範囲比較する際は、精度を揃えてから文字列比較するとシンプルになる。
    • 精度の判定: 正規表現で ^\d{4}-\d{2}-\d{2}$ / ^\d{4}-\d{2}$ / ^\d{4}$ を順に試す。
    • 精度の切り捨て: date.slice(0, N) で YYYY-MM-DD → YYYY-MM → YYYY に切り詰められる(ISO日付形式の特性)。
    • 信頼度の付与: 完全日付(full)で範囲内 → high、部分日付(month/year)で範囲内 → medium、日付不明(none)→ 除外(#514 で変更: 以前は low で常に in range だった)。
    • 例外補正テーブルは { memberId, releaseId, action: 'include' | 'exclude', linkType?, reason } の形で持ち、自動判定ループの前に overrides を先に検索・適用することで、自動ロジックを変えずに個別ケースを補正できる。
  13. React Hooks の refs-during-render ルール(ESLint react-hooks)

    • 親コンポーネントの props 変更に合わせてローカル state を同期するために、useRef で previous props を保持しレンダリング中に読み書きするパターンは、ESLint の react-hooks/refs ルールに違反する。
    • 対策1(1フレーム遅延を許容できる場合): useEffect で props の変更を検知して setState する:
      const [localFilters, setLocalFilters] = useState(filters);
      
      useEffect(() => {
        setLocalFilters(filters);
      }, [filters]);
    • useEffect による同期は 1 フレーム遅れるため、即座の同期が必要な場合は key prop によるコンポーネントリセットを検討する。
    • React 19 以降の ESLint プラグインでは、レンダリング中の ref 操作が厳密に禁止されているため、ref を外部変数の再代入に使う実装は useEffect 内に移動すること。
    • 対策2(即座の同期が必要な場合・#1256): React 公式が認める「レンダリング中の setState」パターンを使う。React は描画前に破棄して再レンダリングするため 1 フレームの遅延・ちらつきが発生しない。useRef と異なり ESLint 違反にもならない:
      const [centerIndex, setCenterIndex] = useState(() => computeInitial(events));
      
      // selectedEventId が現在の events で解決できる限り追従する。
      // 解除時(selectedEventId が null)は centerIndex を更新せず維持する。
      if (selectedEventId) {
        const idx = events.findIndex((e) => e.id === selectedEventId);
        if (idx !== -1 && idx !== centerIndex) {
          setCenterIndex(idx);
        }
      }
    • 「選択解除時は値を更新しない」のように特定の条件でのみ更新したい(=常に props をそのまま反映するわけではない)派生 state は、この対策2が適している。単純な props 反映だけなら対策1で十分。
    • useState の初期値算出(レイジー初期化)にも同じ条件分岐を反映すること。初回マウント時に selectedEventId が既に設定されている場合(URL パラメータ経由など)を考慮しないと、初回レンダリングだけ中心がずれる不具合になる。
    • 「前回 props との比較」ではなく「導出結果自体との比較」にする: prevSelectedEventId のような前回値 state と比較する実装は、selectedEventId は変わらず参照先データ(events)だけが非同期で後から確定するケースで更新が起きなくなる(selectedEventId !== prevSelectedEventId が常に false のため)。idx !== centerIndex のように導出結果そのものと比較すれば、依存する他の値がいつ変化しても正しく追従する。
  14. 複数リソースの一括登録はアトミックに行う(トランザクション)

    • 親レコードと子レコードを別々に作成する際、途中でエラーが発生するとデータ不整合が生じる。
    • ORM を使う場合は $transaction(Prisma)等でラップしてアトミック性を保証する。ORM なしの場合も DB のトランザクション機能(BEGIN / COMMIT / ROLLBACK)を活用すること。
    • createMany(Prisma)等の一括挿入 API を使うと、子レコードを 1 件ずつ挿入するより効率的。
    • 教訓: 親子関係のあるデータ作成は「全件成功か全件ロールバック」が原則。片方だけ成功するケースを許容してはならない。
  15. 部分更新 API のパターン(undefined・空文字列・値の3種類を区別する)

    • undefined: フィールドを更新しない(そのまま保持)
    • "": フィールドを明示的に空にする(DB では NULL)
    • "text": フィールドをその値に更新
    • この3状態を区別することで、「更新しない」と「空にする」を明示的に操作できる API になる:
      if (description !== undefined) {
        updateData.description = description === '' ? null : description;
      }
    • 「全フィールドが undefined」の場合は早期リターンでエラーにするとバリデーションが明確になる。
    • ステータスチェック(例: 下書きのみ更新可能)は「存在確認 → 権限確認 → 状態確認」の順で行うとエラーレスポンスが一貫する。
  16. Blob データのフォーマット変更時は読み取り側にフォールバックを追加する(#384)

    • Blob の書き込みフォーマットを変更した場合(例: フラット配列 → { playlists: [...] } 形式)、デプロイ直後は Blob がまだ旧フォーマットのままとなる。
    • 書き込み側(sync スクリプト)の修正と同時に、読み取り側(getXxxFromBlob())にもフォールバック変換を追加することで、移行期間中も画面クラッシュを防げる:
      const rawData: unknown = await res.json();
      const data: unknown = Array.isArray(rawData) ? { playlists: rawData } : rawData;
      validateXxxBlobData(data);
    • フォールバック変換はバリデーションの前に行い、バリデーション関数側はそのまま新フォーマットのみを検証すれば良い。
    • sync スクリプトの getCurrentXxx() でも旧フォーマットを return [](無視)にするか、変換してマージするかを設計判断すること。どちらにするかによって「Blob 上の旧データが引き継がれるか」が変わる。
  17. GitHub Actions の push + paths フィルターはタグ push で無視される(#419)

    • タグを push する際、GitHub は before SHA をゼロ値として扱い、ファイル差分を判定できない。
    • このため on.push.paths フィルターがスキップされ、ワークフローが無条件で実行されてしまう。
    • 対策: on.push.branches を明示することでタグ push をトリガー対象から除外する:
      on:
        push:
          branches:
            - main  # ブランチのみ対象(タグ push はここで除外される)
          paths:
            - 'data/foo.json'
    • ワークフローが追加されたバージョン以降のタグ push がすべてトリガーになるため、意図しない実行に気づきにくい点に注意。
  18. CLI スクリプトでターミナルのクリッカブルリンクを生成する(#413)

    • OSC 8 エスケープシーケンスを使うと、Windows Terminal・iTerm2 等の対応ターミナルでクリックしてブラウザを開けるリンクを出力できる:
      function formatClickableLink(url: string, label?: string): string {
        const text = label ?? url;
        return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
      }
    • 未対応ターミナルではエスケープシーケンスが表示されるが、URL 自体は含まれているためコピーして利用できる。
    • テストでは result.split(url).length - 1 で URL が2回含まれる(href と表示テキスト)ことを検証できる。
  19. YouTube Data API v3 のクォータ超過を検出して処理を中断する(#427)

    • YouTube API の 403 レスポンスには「権限不足(forbidden)」と「デイリー上限超過(quotaExceeded / userRateLimitExceeded)」の2種類がある。両方とも res.ok = false, status = 403 だがエラー理由が異なるため、ボディを読んで判別する必要がある:
      if (res.status === 403) {
        try {
          const body = await res.json() as { error?: { errors?: { reason?: string }[] } };
          const reason = body?.error?.errors?.[0]?.reason;
          if (reason === 'quotaExceeded' || reason === 'userRateLimitExceeded') {
            throw new YouTubeQuotaExceededError();
          }
        } catch (e) {
          if (e instanceof YouTubeQuotaExceededError) throw e;
          // JSON パース失敗は無視(通常の 403 と同様に処理)
        }
      }
    • 上位ループでは try/catch で YouTubeQuotaExceededError を受け取り break することで、クォータ超過後の無駄な API 呼び出しをなくせる。
    • さらに maxCalls パラメータで上限件数を事前に設けると、クォータ到達前のプロアクティブな打ち切りも可能。
    • テストでは json メソッド付きのモックレスポンスを使い、クォータ超過ボディをシミュレートする:
      vi.fn().mockResolvedValue({
        ok: false, status: 403, statusText: 'Forbidden',
        json: vi.fn().mockResolvedValue({ error: { errors: [{ reason: 'quotaExceeded' }] } }),
      })
  20. Next.js バージョン更新時の確認ポイント(#375 / #1078)

    • Next.js のマイナーバージョンを上げる際は bun add next@X.Y を実行後、eslint-config-next も同バージョンに揃えること(自動更新されない)。
    • 更新後は bun run dev で起動し、Turbopack 関連のエラーログが出ないことを目視確認すること。
    • Turbopack performance.measure 負値バグ(#25 / upstream PR #88688)は Next.js 16.2.1 で再現しないことを確認済(解消されたとみられる)。
    • transitive依存のpeerDependency不整合は bun add だけでは解消されないことがある(#1078): eslint-config-next が間接依存する typescript-eslint はセマンティックレンジ(例: ^8.46.0)を満たしていれば bun は既存のロック済バージョンを維持し、レンジ内でより新しいバージョンが存在してもリゾルブし直さない。eslint-config-next 更新後も peerDependency 警告(例: typescript バージョン不整合)が解消されない場合は、該当パッケージを明示的に bun update <package> --latest で更新すること。
    • Next.js 16.3 では next dev 実行時に AGENTS.md を自動生成・更新する機能が追加された(<!-- BEGIN:nextjs-agent-rules -->〜<!-- END:nextjs-agent-rules --> マーカー範囲のみ upsert、マーカー外や CLAUDE.md 等の別ファイルは変更されない安全な実装)。差分が出るのは想定通りなので、そのままコミットしてよい(next.config の agentRules: false でオプトアウトも可能)。
  21. スクリプト内ローカル型に [key: string]: unknown がある場合の型キャスト回避(#472)

    • スクリプト固有の型(例: build-playlist-index.ts の Release)は types/ の型定義を import せず、ファイル内にインラインで定義している場合がある。
    • このような型に [key: string]: unknown インデックスシグネチャが付いている場合、実際に存在するフィールドも unknown として扱われ、直接インデックスアクセスや文字列メソッド呼び出しで型エラーが出る。
    • 対策: as string でキャストするのではなく、使用するフィールドを型定義に明示的に追加する。これにより型安全性が向上し、不要なフォールバック (?? '...') も除去できる:
      // Before: as string キャスト+フォールバック
      type Release = { id: string; tracks?: Track[]; [key: string]: unknown };
      const formatA = (a.release.format as string) ?? 'other';
      // After: フィールドを型定義に追加
      type Release = { id: string; format: string; releaseDate: string; tracks?: Track[]; [key: string]: unknown };
      const prioA = FORMAT_PRIORITY[a.release.format] ?? FORMAT_PRIORITY.other;
  22. 複数ファイルにまたがる共有型の重複定義に注意(#509 / #510)

    • types/comparison.ts に ReleaseFormat が types/release.ts とは独立して定義されていた(#491 でドメイン分離した副産物)。
    • 共有型をユニオンに拡張する際、片方の更新を忘れると型エラーや実行時の不整合が生じる。
    • 対策: 共有型は一箇所で定義し、他のファイルは import type { X } from '...' + export type { X } で re-export する。
      // types/comparison.ts
      import type { ReleaseFormat } from './release';
      export type { ReleaseFormat };
  23. import.meta.url + fileURLToPath は Windows では必須だが jsdom テストでは要モック(#543)

    • new URL('./foo.json', import.meta.url).pathname は Windows Bun 実行時に /D:/...(先頭スラッシュ付き)を返し、writeFileSync が ENOENT でクラッシュする。
    • 修正: fileURLToPath(new URL('./foo.json', import.meta.url)) を使うと OS ネイティブなパス(D:\...)が得られる。
    • テスト対応: Vitest の jsdom 環境では import.meta.url が file: スキームでないため fileURLToPath が ERR_INVALID_URL_SCHEME をスローする。vi.mock('url', ...) で fileURLToPath をモックすること:
      vi.mock('url', () => {
        const fileURLToPath = vi.fn((url: URL | string) => (typeof url === 'string' ? url : url.pathname));
        return { fileURLToPath, default: { fileURLToPath } };
      });
    • default キーも返さないと Vitest が「No ‘default’ export is defined」エラーを出す点に注意。
  24. メンバー紐づけロジックに実質基準日を導入するとテスト設計が根本的に変わる(#514)

    • 加入日を基準に “翌月1日以降の最初のシングル” を実質基準日として採用すると、単一リリースしか渡さないテストの大半が成立しなくなる。
    • 理由: 実質基準日の算出自体がリリース一覧から最初のシングルを探す処理に依存するため、シングルが含まれていないとすべてのリリースが除外される。
    • 対策: テストは「シングルを先頭に置いて effectiveJoinDate を確定させ、その後に検証対象のリリースを渡す」構成にする。各テストに makeRelease({ format: 'single', ... }) を含めることを意識する。
    • precision='none'(日付不明)も従来は low 信頼度で in range だったが、このロジック変更で in range=false(除外)に変わった。テスト上も「除外される」に更新すること。
  25. MusicBrainz の artist-credits 取得と複数期ユニットの年次照合(#562)

    • fetchTracksForRelease の API エンドポイントに artist-credits を追加(inc=recordings+artist-credits)することでトラックごとのアーティスト名を取得できる。
    • artist-credit[0].name(クレジット表記名)が存在する場合は artist-credit[0].artist.name(正式名)より優先する(例: アルバム掲載名と正式名が異なる場合がある)。
    • 複数期がある日本語ユニット名(例: “タンポポ(第1期)”)の照合は「括弧前のベース名でグループ化 → リリース年が activeFrom〜activeTo に含まれる期を選択」で行う。境界年(例: activeTo=2002 と activeFrom=2002 が重なる)では activeFrom が最大の期(最新期)を採用する。
    • これにより、既存の Track.unitId が設定済のトラックは再処理で上書きされない(手動修正保護)。
  26. Prisma 7 の設定構造と Neon アダプターの使い方(#609)

    • Prisma 7 では schema.prisma の datasource ブロックに url = env("...") を書けなくなった。接続 URL は prisma.config.ts の datasource.url に移動する:
      // prisma.config.ts
      import { defineConfig } from 'prisma/config'
      export default defineConfig({
        schema: './prisma/schema.prisma',
        datasource: { url: process.env.DIRECT_URL ?? process.env.DATABASE_URL ?? '' },
        migrations: { seed: 'bun run prisma/seed.ts' },
      })
    • PrismaNeon(@prisma/adapter-neon)のコンストラクタは Pool インスタンスではなく PoolConfig オブジェクトを受け取る:
      // NG: new PrismaNeon(pool)  // Pool インスタンスは不可
      // OK: new PrismaNeon({ connectionString })
      const adapter = new PrismaNeon({ connectionString: process.env.DATABASE_URL })
      const prisma = new PrismaClient({ adapter })
    • prisma.config.ts の migrate.adapter() は存在しない(旧ドキュメントの誤り)。マイグレーション用 URL は datasource.url のみ。
    • Neon の pooled URL(DATABASE_URL)と direct URL(DIRECT_URL)を使い分ける場合: マイグレーションには DIRECT_URL、クライアントには DATABASE_URL を渡す。
  27. Prisma CLI は .env.local を読み込まない(#638)

    • Next.js の規約では環境変数を .env.local に書くが、Prisma CLI(prisma migrate deploy 等)が自動読み込みするのは .env のみ。
    • .env.local のみ存在する環境では process.env.DATABASE_URL が undefined になり、prisma.config.ts の接続 URL が空文字になって以下のエラーが発生する:
      Error: Connection url is empty. See https://pris.ly/d/config-url
    • 対策: prisma.config.ts で dotenv を使って .env.local を明示的に読み込む:
      import { defineConfig } from 'prisma/config'
      import { config } from 'dotenv'
      
      // Prisma CLI は .env.local を自動読み込みしないため明示的にロードする
      config({ path: '.env.local' })
      
      export default defineConfig({ ... })
    • dotenv は Next.js の依存として既にインストール済のため、追加インストール不要。
    • bun run db:seed は Bun が .env.local を自動読み込みするため対応不要。
  28. Zod v4 スキーマ導入パターンと型定義の一元管理(#629)

    • lib/blob.ts のバリデーターと types/ の型定義が乖離するリスクを排除するため、Zod スキーマを型ファイル(types/*.ts)に定義し、型は z.infer<> から自動導出する方針を採用。

    Zod v4 固有の注意点:

    • issue.path の型が PropertyKey[](symbol を含む)に変わった。(string | number)[] としてキャストする必要がある:
      const path = issue.path as (string | number)[];
    • z.ZodIssueCode.custom が非推奨。文字列リテラル 'custom' を使う:
      ctx.addIssue({ code: 'custom', path: [...], message: '...' });

    スキーマ定義の注意点:

    • Zod はスキーマのフィールド定義順にエラーを報告する(最初に定義されたフィールドのエラーが issues[0] になる)。テストでエラーメッセージを検証している場合、スキーマのフィールド順は既存バリデーターの検証順と一致させること。

    新規型定義への適用ガイドライン:

    • 新しい型(ライブ機能等)は最初から Zod スキーマとして定義し、z.infer<> で型を導出する。後からの移行コストを避けられる:
      // types/live.ts
      import { z } from 'zod';
      export const LiveSchema = z.object({ ... });
      export type Live = z.infer<typeof LiveSchema>;
    • lib/blob.ts 等でバリデーターが必要な場合は formatZodError() ヘルパーを再利用する。
  29. declare global { var ... } と ESLint no-var ルールの非干渉(#516)

    • TypeScript の declare global ブロック内に書く var は ESLint の no-var ルールの検出対象外。
    • Next.js の Prisma シングルトンパターン(開発時のホットリロード対策)でよく使われる以下の構文は eslint-disable コメント不要:
      declare global {
        var prismaClient: PrismaClient | undefined;
      }
    • 誤って eslint-disable-next-line no-var を書くと「Unused eslint-disable directive」警告が出る。
  30. react-leaflet を Next.js App Router で使う際の注意点(#525)

    • react-leaflet は SSR 非対応のため、Server Component 内で直接 dynamic + ssr: false は使えない(Turbopack ビルドエラーになる)。
    • 正しいパターン: 'use client' を持つ薄いラッパーコンポーネント(例: VenueMapClient.tsx)を作り、その中で dynamic(() => import('./VenueMap'), { ssr: false }) を行う。Server Component からはラッパーを呼ぶ。
    • 地図コンポーネント自体にも 'use client' を付与し、leaflet/dist/leaflet.css のインポートもそのファイル内に書く(グローバル CSS への混入を避けられる)。
    • Leaflet デフォルトマーカーは Next.js の画像ローダーと相性が悪く broken image になる。CircleMarker を使うと画像なしでマーカーを表示でき、この問題を回避できる。
    • 地図の表示範囲を会場に自動フィットするには useMap() フックを持つ子コンポーネントを作り useEffect 内で map.fitBounds(positions, { padding: [...] }) を呼ぶ。
  31. @xyflow/react を Next.js App Router で使う際の注意点(#1209)

    • @xyflow/react は SSR 非対応のため、ページコンポーネント全体を 'use client' にする必要がある。react-leaflet(#30)と異なり、ページ全体が 'use client' であれば dynamic + ssr: false のラッパーは不要。
    • nodeTypes / edgeTypes はコンポーネント外(モジュールレベル)で定義する: コンポーネント関数内で定義すると毎レンダーに新しいオブジェクトが生成され、React Flow がすべてのノード/エッジを再マウントしてパフォーマンスが劣化する。
    • SVG linearGradient をカスタムエッジ内に定義できる: React Flow はすべてのエッジを単一の <svg> 要素内に描画するため、カスタムエッジコンポーネントで <defs> → <linearGradient id={gradientId}> を定義し stroke: url(#${gradientId}) で参照できる。ただし gradientId は edge ごとにユニークにすること(gradient-${id} 等)。
    • @xyflow/react/dist/style.css のグローバル CSS インポートは 'use client' のページファイル内で行うと良い(ページスコープに限定できる)。
  32. 先行 PR ブランチをベースにした PR は Closes #XXX による Issue 自動リンクが機能しない(#669)

    • GitHub の「Closes #XXX で Issue を自動リンク・クローズ」機能は、PR がデフォルトブランチ(main)にマージされる場合にのみ有効。
    • /next-issue の「一緒に対応できる項目」などで先行 PR のブランチをベースにした PR を作成すると、Closes #XXX を本文に記載しても GitHub の Development 欄に Issue リンクが表示されない。
    • この場合は PR 画面の「Development」欄から手動で Issue を紐付けること。
  33. Ameba グループブログの RSS メンバー振り分けはフォールバック設計にする(#679)

    • morningmusume16ki 等の複数メンバー共有ブログは RSS に全員の投稿が混在するため、parseAmebaRss にメンバー名フィルターを追加した。
    • ただし fetchAmebaRss は個人ブログか共有ブログかを区別できないため、「メンバー名を含む item を優先し、見つからない場合は先頭 item にフォールバック」とした。
    • フォールバック時の挙動: 個人ブログ(名前が本文に出ない)→ 先頭で OK。共有ブログでメンバーが最近投稿していない→ 他メンバーの投稿が入る(許容)。
    • 個人ブログのテストでは member name を RSS 内に含めなくても通るが、グループブログのテストでは member name を description/title に明示すること。
  34. Inno Setup を winget でインストールすると %LOCALAPPDATA%\Programs に配置される(#684)

    • winget install JRSoftware.InnoSetup.7 でインストールした場合、ISCC.exe は Program Files (x86) ではなく %LOCALAPPDATA%\Programs\Inno Setup 7\ISCC.exe に配置される(ユーザースコープインストール)。
    • ISCC を直接呼ぶスクリプトは複数のパスを試して自動検出する設計にすること(setup/build-installer.ps1 参照)。
    • インストーラのバージョンは setup/build-installer.ps1 が root の package.json から読み取り /DAppVersion=X.Y.Z として Inno Setup に渡す。詳細なビルド手順は docs/operations/desktop-build.md を参照。
  35. Inno Setup の { はエスケープが必要(#690)

    • Inno Setup スクリプトの [Setup] セクションでは { が定数参照の開始記号として解釈される。GUID を AppId に設定する際、そのまま {GUID} と書くと GUID を定数として探してエラーになる。
    • #define AppId "{{GUID}" のように最初の { を {{ でエスケープすること。AppId={#AppId} で展開後、パーサーが {{ を { として解釈し {GUID} が AppId の値になる。
    • NG: #define AppId "{EEFB651A-...}" → Unknown constant "EEFB651A-..." エラー
    • OK: #define AppId "{{EEFB651A-...}" → 正しく {EEFB651A-...} として登録される
  36. Next.js standalone サーバーの .env.local 読み込みと Bun Worker でのパス解決(#705)

    • output: 'standalone' ビルドで生成される .next/standalone/server.js は、process.cwd()(実行時のカレントディレクトリ)から .env.local を読み込む。server.js と同じディレクトリを cwd に指定すれば .env.local を自動ロードできる:
      Bun.spawn([bunExe, serverJs], {
        env: { ...process.env, DESKTOP_MODE: "1", HOSTNAME: "localhost", PORT: port },
        cwd: standaloneDir, // server.js と .env.local を同じディレクトリに置く
      });
    • standalone の静的ファイルは手動コピーが必要: next build では .next/static/ は standalone ディレクトリに含まれない。サーブ前に .next/static/ → .next/standalone/.next/static/ および public/ → .next/standalone/public/ をコピーする(またはインストーラでコピーする)。
    • Bun Worker での import.meta.dir: Worker のエントリポイントファイルのディレクトリを返す。インストール済アプリでは {app}/Resources/app/bun/ となるため、上位ディレクトリへの相対パスでアプリ構造をたどれる:
      const resourcesDir = join(import.meta.dir, "..", ".."); // {app}/Resources/
      const bunExe = join(resourcesDir, "..", "bin", "bun.exe"); // {app}/bin/bun.exe
    • output: 'standalone' は DESKTOP_MODE=1 時のみ有効にすると Vercel デプロイに影響を与えない(next.config.ts で process.env.DESKTOP_MODE === '1' で条件分岐)。
  37. Vitest で vi.mock ファクトリから await import でモジュールを参照するパターン(#617)

    • vi.mock(...) のファクトリ関数はモジュールのインポートより前にホイストされるため、ファクトリ内でモジュールレベルの const を参照すると TDZ エラーになる。
    • 対策: テスト関数または beforeEach 内で await import('...') を使ってモジュール参照を取得する。トップレベルの static import は使わない:
      // NG: static import は vi.mock ホイスティングと競合して TDZ エラー
      import { put } from '@vercel/blob';
      // OK: テスト関数内で動的に取得
      it('test', async () => {
        const { put } = await import('@vercel/blob');
        expect(vi.mocked(put)).toHaveBeenCalled();
      });
    • beforeEach を async にして共通セットアップで put を取得し、各テストでも await import で取得すれば vi.mocked(put) が正しく機能する。
    • vi.clearAllMocks() は呼び出し履歴だけでなくモックの返り値もクリアするため、beforeEach で mockResolvedValue をセットし直すこと。
  38. JSX 数値 children は JSON.stringify で引用符なしの数値になる(#617)

    • Server Component の JSX ツリーを JSON.stringify するテストで数値を検証する場合、数値型の children は "children":"81" ではなく "children":81(引用符なし)としてシリアライズされる。
    • expect(html).toContain('"children":81') のように数値型で比較すること。
  39. デスクトップアプリのバージョン体系は Web アプリとは独立している(#711)

    • root の package.json(例: 3.3.0)は Web アプリおよび Windows インストーラのバージョン。build-installer.ps1 がこれを読んで /DAppVersion として Inno Setup に渡す。
    • desktop/electrobun.config.ts と desktop/package.json は Electrobun アプリ(macOS アプリバンドル)のバージョン(例: 0.0.2)。これらは独立して管理する。
    • バージョンを上げる際はどちらのバージョンを指しているか確認すること。
  40. Bun スクリプトで PNG から ICO ファイルを生成できる(#712)

    • ICO フォーマットは「ICONDIR(6 bytes)+ ICONDIRENTRY(16 bytes × 画像数)+ 画像データ」のシンプルな構造。PNG をそのまま埋め込める(PNG 埋め込み ICO)。
    • DataView で ICO ヘッダを構築し、PNG バイト列を追記するだけで有効な .ico が生成できる:
      const pngBytes = new Uint8Array(await Bun.file(pngPath).arrayBuffer());
      const buf = new ArrayBuffer(6 + 16 + pngBytes.length);
      const view = new DataView(buf);
      view.setUint16(0, 0, true); view.setUint16(2, 1, true); view.setUint16(4, 1, true); // ICONDIR
      view.setUint8(6, size); view.setUint8(7, size);            // width / height (0 if > 255)
      view.setUint16(10, 1, true); view.setUint16(12, 32, true); // Planes / BitCount
      view.setUint32(14, pngBytes.length, true);                 // BytesInRes
      view.setUint32(18, 22, true);                              // ImageOffset
      new Uint8Array(buf, 22).set(pngBytes);
      await Bun.write(icoPath, buf);
    • macOS 用 .iconset は sharp(Next.js 依存として利用可)で各サイズへリサイズして生成できる。ソース画像は 512px 以上が望ましい(低解像度からの拡大はドット感が出る)。
  41. バッチ収集側で対象を絞り込み、表示側はデータ駆動にする(#774)

    • バッチ(sync-discography 等)で URL を収集する際、対象フォーマットを絞り込む(例: youtube-short はシングルのみ)。
    • 表示側でフォーマットを再チェックするのではなく、「データ(リンク)が存在すれば表示」とするほうがシンプルで拡張に強い:
      // NG: 表示側でフォーマット再チェック
      const isSingleFormat = occurrences.some(o => o.release.format === 'single');
      const shortLinks = isSingleFormat ? links.filter(l => l.type === 'youtube-short') : [];
      // OK: データ駆動(バッチが収集時に絞り込んでいれば信頼できる)
      const shortLinks = links.filter(l => l.type === 'youtube-short');
    • 利点: 将来バッチが他のフォーマットにも対応した場合、表示側を変更不要(バッチと UI の責任を明確に分離)。
  42. 再取得バッチでの「空結果 = 意図的削除」を正しくキャッシュ判定する(#823)

    • 初回取得: 結果が空のとき cache を更新しない(else if (videoIds.length > 0) 分岐)。
    • 再取得(shortsRefetchRequested === true): 結果が空でも trackLinksCache.set(trackId, []) で旧 Shorts を消す。
    • 最終 mapping でキャッシュの判定を links && links.length > 0 にすると空配列を「未処理」と誤判定し、削除が反映されない。
    • 修正後は links === undefined(未処理)と links = [](意図的な空)を区別する:
      if (links === undefined) return flagCleared ? { ...track, shortsRefetchRequested: undefined } : track;
      if (flagCleared) return { ...track, links, shortsRefetchRequested: undefined };
      return links.length > 0 ? { ...track, links } : track;
  43. Prisma マイグレーションの作成手順: スキーマ修正 → コマンド生成(#850)

    • 正しい手順: prisma/schema.prisma を修正してから bunx prisma migrate dev --name <name> を実行し、マイグレーション SQL を自動生成する。
    • 手書き SQL は厳禁。Prisma が生成する制約名(テーブル名_カラム名_key 等)と手書きの命名が食い違うと、後続の prisma migrate が差分を誤検知する原因になる。
    • DB 接続がない環境では migrate dev が実行できないため、その場合はユーザーに状況を伝えて判断を仰ぐこと。
  44. PostgreSQL の camelCase カラム名は CREATE INDEX でも必ずダブルクォートで囲む(#853)

    • PostgreSQL は識別子を 小文字に正規化するため、CREATE INDEX ... ON table (venueId) と書くと venueid として解釈される。
    • "venueId" と二重引用符で囲まれたカラムと不一致になり column "venueid" does not exist エラーが発生する。
    • 手書きマイグレーション SQL では常に "venueId" のようにダブルクォートを付けること(Prisma 自動生成 SQL は正しくクォートする)。
  45. Prisma migrate dev: マイグレーションファイル変更後のチェックサムズレを DB 更新で解消(#853)

    • migrate dev が「The migration was modified after it was applied.」と報告する場合、_prisma_migrations テーブルのチェックサムが変更後のファイルと一致していない。
    • データを保持したまま解消する手順:
      1. node -e "const crypto=require('crypto');const fs=require('fs');console.log(crypto.createHash('sha256').update(fs.readFileSync('prisma/migrations/<name>/migration.sql','utf8')).digest('hex'));" でファイルの SHA256 を取得する
      2. bunx prisma db execute --stdin で UPDATE "_prisma_migrations" SET checksum = '<hash>' WHERE migration_name = '<name>'; を実行する
    • prisma migrate reset(全データ消去)は最終手段。開発 DB でもデータが残せる場合は上記手順を優先すること。
  46. Bun スクリプトのエントリポイントガード: import.meta.main(#854)

    • スクリプトファイルのトップレベルで main() を直接呼び出すと、Vitest がファイルをインポートした際にも main() が実行されてしまい process.exit でテストが異常終了する。
    • Bun の解決策: if (import.meta.main) { main(); } でガードする。import.meta.main は Bun が実行のエントリポイントとして呼び出した場合のみ true になる(import 時は false)。
    • Node.js 環境での等価イディオム(ESM): import { fileURLToPath } from 'url' + if (process.argv[1] === fileURLToPath(import.meta.url)) { main(); } で同様の効果が得られる。
  47. サードパーティ CDN 画像は <Image unoptimized> で表示する(#805)

    • TikTok のサムネイルは p16-sign.tiktokcdn.com など多様かつ動的なドメインから配信される。next.config.ts の remotePatterns にすべてのドメインを列挙するのは現実的でない。
    • <Image unoptimized> を使うと Next.js の画像最適化プロキシをバイパスし、URL をそのまま <img src> に渡せる。CORP ヘッダーによるブロックは発生しない(最適化プロキシ経由でないため)。
    • referrerPolicy="no-referrer" も合わせて指定し、リファラーによるアクセス制限を回避すること。
    • 画像最適化(WebP変換・リサイズ)は行われないため、パフォーマンス上のトレードオフがある。固定ドメインが確定しているサービス(Instagram・YouTube 等)は引き続き remotePatterns を使うこと。
  48. "use client" ファイルへの import 追加時はサーバー専用モジュールの混入を確認すること(#913)

    • "use client" コンポーネントが、トップレベルで prisma・fs・next/headers 等のサーバー専用モジュールを import しているファイルを(間接的にでも)import すると、クライアントバンドルにサーバーコードが混入し DATABASE_URL 等のランタイムエラーが発生する。
    • この種のエラーは tsc --noEmit・vitest・eslint では検出されず、ブラウザでページを開いて初めて発覚する。
    • 実装時チェック: "use client" ファイルに新しい import を追加するときは、import 先ファイルのトップレベル import を1段確認し、サーバー専用モジュールが含まれていないかチェックすること。
    • 設計原則: 純粋関数・型変換など「どこでも使える」コードは、DB/サーバー処理を含むファイルに混在させず *-utils.ts のような独立ファイルに切り出すこと。
    • 参考: lib/tiktok-utils.ts(isTikTokThumbnailValid を lib/tiktok.ts から分離した例)
  49. Neon/Vercel Blob の月間転送量削減: unstable_cache と next: { revalidate } の使い分け(#944 #945)

    • Prisma クエリ(Neon)は unstable_cache(next/cache)でラップし、TTL を設定することでリクエストをまたいだキャッシュが有効になる。fetch ベースの関数には適用不可。
    • fetch ベースの関数(Vercel Blob)は { cache: 'no-store' } を { next: { revalidate: N } } に変更するだけで Next.js の Data Cache が効く。unstable_cache のラップは不要。
    • unstable_cache は Next.js サーバーコンテキスト外では使用不可(#1002): Vitest 環境・Bun スクリプト(GitHub Actions)のいずれでも Invariant: incrementalCache missing in unstable_cache エラーが発生する。
      • Vitest: test/setup.ts に vi.mock('next/cache', () => ({ unstable_cache: (fn) => fn })) を追加して回避する。
      • Bun スクリプト: unstable_cache でラップされた関数(例: getReleases)を scripts/ から呼ぶと毎回エラーになり、try/catch のフォールバックで空データを返す。スクリプトからは unstable_cache を使わない 生の関数(例: fetchReleasesFromDB)を別途 export して使うこと。
    • force-dynamic ページでも unstable_cache / next: { revalidate } のデータキャッシュは独立して機能するため、ページ全体の動的レンダリングとデータのキャッシュは排他ではない。
    • unstable_cache は戻り値をJSONシリアライズしてキャッシュ保存するため、Date 型のフィールドはキャッシュヒット時に Date インスタンスではなく単なる文字列になって返る(#1551): キャッシュミス直後(初回呼び出し)は関数の生の戻り値がそのまま返るため Date のままエラーが出ず、2回目以降のキャッシュヒットで初めて xxx.getUTCHours is not a function のような実行時エラーが発覚する。unstable_cache でラップする関数は最初から Date を返さず、record.capturedAt.toISOString() のように ISO 文字列で返す設計にすること(lib/tiktok.ts の mapPost 等、既存の全 unstable_cache ラップ関数がこのパターン)。呼び出し側で実際に Date 演算が必要な箇所(getUTCHours() 等)だけ new Date(isoString) で変換する。
  50. Instagram サーバーサイド CDN チェックは廃止・クライアント側 onError に移行(#875)

    • Instagram CDN はボット判定で並列リクエストをブロックしやすい。filterInaccessiblePosts で直列にサムネイル URL を検証していた(#951)。
    • #875 で方針転換: サーバーサイドでの Instagram CDN アクセスチェックは信頼性が低く SSR パフォーマンスの妨げになるため、Next.js アプリ側では行わない。filterInaccessiblePosts を lib/instagram-utils.ts ごと廃止。
    • 正しいパターン: thumbnailUrl が null の投稿はサーバー側でフィルタ(アクセス可否チェックは不要)。画像読み込み失敗時は <Image> の onError でクライアント側フォールバック UI(「画像を表示できません」)を表示する(InstagramCoverCard 参照)。
    • スクリプト側: collect-instagram-posts.ts での Blob 書き込み前サムネイル検証は引き続き有効。cache: 'no-store' は Bun 環境で無効(silently ignored)なので削除すること。
  51. X(旧Twitter)API v2 は新規開発者向け無料プランが廃止済(#918)

    • 2026年2月以降、X API v2 の新規開発者向け Free tier は廃止。Pay-per-use($0.005/読み取り)がデフォルト。
    • 検索機能(過去投稿の遡及取得)は Basic($200/月)以上が必要であり、個人プロジェクト規模では費用対効果が低い。
    • 代替手段: RapidAPI の Twitter スクレイパー(twitter-scraper2 等)を利用する。既存の TikTok 収集スクリプト(scripts/collect-tiktok-posts.ts)と同一の RapidAPI パターンで実装可能であり、RAPIDAPI_KEY Secret を共有できる。
    • 詳細調査: docs/investigations/issue-918-x-onair-data-collection.md
  52. STVラジオサイト(stv.jp/radio)の楽曲リストは静的 HTML で取得可能(#919)

    • https://www.stv.jp/radio/bonsoir/senkyoku/ 配下のページは静的 HTML。JavaScript レンダリング不要で fetch で取得できる。
    • 楽曲データフォーマット: M1「曲名」(アーティスト名) を <p> タグにプレーンテキストで記載。正規表現でパース可能。
    • 過去回の URL はランダムな英数字 ID 形式(d1tpnXXXXXXXXXX.html)。index.html から過去回リンクをたどる方式で遡及取得する。
    • 既存スキーマの制約: RadioOnairSong にアーティスト名フィールドがない。モーニング女学院と異なりメイボンソワは外部アーティスト楽曲が多いため、artistName カラムの追加が必要(#922 で対応)。
    • 詳細調査: docs/investigations/issue-919-stv-bonsoir-data-collection.md
  53. HTML スクレイパーのテストでモック HTML 内の URL は抽出 regex の文字クラスに合わせること(#923)

    • extractPastLinks のような関数でリンクを正規表現で抽出する場合、regex の文字クラスがテストデータに適合しているかを確認すること。
    • 例: href="(\/radio\/bonsoir\/senkyoku\/[a-z0-9]+\.html)" は英数字のみにマッチする。テストのモック HTML でハイフン入りの URL(past-yamazaki.html 等)を使うとリンクが抽出されず、テストが意図通りに通らない。
    • 対策: テスト用 URL は実際のサイトが使う命名規則に合わせる(例: d1abc20000001234.html のような英数字のみのID)。スクレイパーの regex とテストデータの整合性は新規テスト追加時に必ず確認すること。
    • 同様に、絵文字など複数箇所に出現し得る区切り文字を regex のアンカーに使う場合は、直前のコンテキスト(曲目\s*🔔(.+?)[/\/] 等)も含めてパターンを組むことで誤マッチを防ぐ。
  54. Next.js 16 の revalidateTag は第2引数必須・呼び出し元によって使い分ける(#1000 #1065)

    • Next.js 16 で next/cache の revalidateTag のシグネチャが revalidateTag(tag: string, profile: string | CacheLifeConfig) に変更され、TypeScript 上は第2引数が必須になった(ランタイムは省略可能だが deprecation warning が出る)。
    • Server Action から呼ぶ場合: 代わりに updateTag(tag: string) を使うこと。引数1個のみで型エラーなく使用できる。
      // Server Action 内
      import { updateTag } from 'next/cache';
      updateTag('releases');
    • Route Handler から呼ぶ場合: updateTag は Server Action 専用のため使用不可(ランタイムエラー)。revalidateTag(tag, 'max') を使うこと。
      // Route Handler 内(app/api/*/route.ts)
      import { revalidateTag } from 'next/cache';
      revalidateTag('releases', 'max'); // 第2引数 'max' で deprecation warning を抑制
    • unstable_cache に tags: ['releases'] オプションを付与した上でいずれかを呼ぶことで、キャッシュを即時無効化できる。
    • テストでは vi.mock('next/cache', () => ({ revalidateTag: vi.fn(), updateTag: vi.fn() })) でモックする。
  55. vitest の TZ: 'Asia/Tokyo' 設定が UTC 本番環境のタイムゾーンバグを隠す(#1041)

    • vitest.config.ts に env: { TZ: 'Asia/Tokyo' } を設定するとテストは JST で動作する。そのため new Date('2026-05-30T00:00:00+09:00') の getDate() はテスト環境では 30(JST)を返し正しく見える。
    • しかし GitHub Actions(UTC 環境)では getDate() が UTC で評価されるため 29(前日)を返し、episodeId が 1 日ずれるバグが発生する。
    • 対策: T00:00:00+09:00 のような JST midnight リテラルの代わりに setHours(0, 0, 0, 0) や new Date(year, month - 1, day) を使ってローカル midnight を生成すると、テスト(JST)と本番(UTC)の両方で getDate() が期待通りの日付を返す。
    • 同一プロジェクト内で複数の「midnight Date 生成パターン」が混在している場合は特に注意が必要。例: nearestSaturday が setHours(0, 0, 0, 0) を使う一方で、別の関数が T00:00:00+09:00 を使うと formatEpisodeId の結果が食い違う。
    • 根本方針: ローカル時刻メソッド(getDate() / getDay())を使って日付を読む関数は、同じくローカル midnight(setHours(0, 0, 0, 0))で生成された Date を渡すこと。UTC 固定や JST 固定のリテラルを混ぜない。
  56. MusicBrainz の recording.video フラグと全エディションマージによる楽曲収集(#1055)

    • GET /release/{id}?inc=recordings+artist-credits のレスポンスに含まれる media[].tracks[].recording.video(boolean)は追加の inc パラメータなしで取得できる。true の場合は映像トラック(Dance Shot Ver.・メイキング映像等)であることを示す。
    • 日本のシングルは同一リリースグループ内に複数エディション(Type A / Type B / 限定盤等)があり、エディションごとにカップリング曲が異なる場合がある。fetchFirstReleaseIdForReleaseGroup で1エディションのみ取得する設計では、選ばれなかったエディション固有のカップリング曲が丸ごと欠落する。
    • 対策: fetchAllReleaseIdsForReleaseGroup で全エディションの ID を取得し、各エディションのトラックを recording UUID でマージする(同一楽曲は同一 UUID を持つため重複しない)。
  57. prisma.track.upsert の update 句に releaseId を含めないと、コンピレーション先行登録トラックがシングルに移行しない(#1061)

    • saveDiscographyToNeon の trackMap は format 優先度(single=1 > album=2 > compilation=4)でトラックの帰属リリースを決定するが、prisma.track.upsert の update 句に releaseId を含めていなかったため、コンピレーション等で先に登録されたトラックが UPDATE パスで releaseId を更新されず、シングル配下に移行しなかった。
    • 結果として REFETCH_ALL_SINGLES=1 を実行してもシングルに紐づくべきトラックが tracks テーブルのシングル releaseId で見つからない状態になっていた。
    • 対策: update 句に releaseId を追加し、format 優先度で選ばれた勝者リリースに releaseId を移動させる。
  58. Prisma update の data に undefined を渡すとそのフィールドは更新されない(#1070)

    • Prisma の update 呼び出しで data オブジェクトのフィールドが undefined の場合、Prisma はそのフィールドを SQL UPDATE 文に含めない(null を明示した場合は NULL に更新される)。
    • この挙動を利用すると、「省略時は既存値を保持・明示 null では NULL に更新」というパターンを以下の式で実現できる:
      imageUrl: data.imageUrl !== undefined ? data.imageUrl : existing.imageUrl
    • data.imageUrl ?? null では undefined と null を区別できず、どちらも NULL に更新してしまう点に注意。
  59. Prisma の camelCase カラムは RAW SQL でダブルクォートが必要(#1092)

    • Prisma は @map なしのフィールド名をそのまま PostgreSQL カラム名として使う。PostgreSQL は識別子を小文字に正規化するため、"sourceId" のように生成時はダブルクォートでラップしてケースを保持する。
    • $queryRaw で camelCase カラムを参照する場合、クォートなしの sourceId は PostgreSQL が sourceid として解釈し column "sourceid" does not exist エラーになる。
    • 対策: 必ずマイグレーション SQL(prisma/migrations/*/migration.sql)を開き、実際のカラム定義を確認してから $queryRaw を書くこと:
      -- 正しい (migration.sql に "sourceId" TEXT NOT NULL と定義されている場合)
      SELECT id, date, type, title, link, "sourceId" FROM events WHERE ...
      -- NG: sourceid / source_id はいずれも存在しない
    • $queryRaw の TypeScript 型パラメータのキーも sourceId(camelCase)に揃えること(Prisma がクエリ結果をパースする際にダブルクォートを除いた名前でオブジェクトキーを返す)。
  60. scripts/ の import 漏れは type-check で検出されず、try/catch 内では実行時も無音で失敗する(#1114)

  • scripts/ ディレクトリは tsconfig.json の exclude に入っているため、bun run type-check は import 漏れ(未定義識別子の参照)を検出しない。
  • Bun 実行時には ReferenceError がスローされるが、該当コードが try/catch 内にあるとエラーが握りつぶされ、フォールバック値(空配列等)が返り続ける無音の機能不全になる。
  • 実例: collect-instagram-posts.ts の fetchExistingDailyPosts が DailyNewPostsSchema を import せずに参照しており、catch で常に [] を返すため同日複数回実行時のデイリー投稿マージが機能していなかった。
  • 対策: スクリプト内の Blob 読み込み関数等は export して単体テストを書くこと(テストが import 漏れを ReferenceError として顕在化させる)。catch で握りつぶす関数は特にテスト必須。
  1. Prisma の動的 where 句には Prisma.XxxWhereInput 型を使う(#1111)
  • Parameters<typeof prisma.event.findMany>[0]["where"] で取得できる型は EventWhereInput | undefined であり、スプレッド演算子やプロパティ代入で TS2339 エラーが出る。
  • 正しいパターン: import type { Prisma } from "@prisma/client" でインポートし、const where: Prisma.EventWhereInput = { ... } と型注釈する:
    import type { Prisma } from "@prisma/client";
    
    const where: Prisma.EventWhereInput = {
      date: dateRange,
      ...(selectedTypes.length > 0 ? { type: { in: selectedTypes } } : {}),
      ...(selectedTags.length > 0 ? { tags: { hasSome: selectedTags } } : {}),
      ...(selectedMemberIds.length > 0 ? { members: { hasSome: selectedMemberIds } } : {}),
    };
    const events = await prisma.event.findMany({ where });
  • 配列カラムには hasSome、スカラーカラムには in を使い分けること。未選択時はそのフィールドを where に含めないことで全件対象になる。
  1. ISO 8601 年の最終週番号は 12月28日の週番号で取得する(#1111)
  • ISO 8601 では「1月4日を含む週が第1週」と定義されているため、12月28日は必ず当年の最終週(第52週または第53週)に含まれる。
  • 独自計算(Math.ceil(...) による曜日ベース計算)は境界条件でバグを引き起こしやすい。既存の getIsoWeek 関数を再利用すること:
    // NG: 独自計算は境界条件でバグが出る(2024年→5、2015年→4 等の誤値)
    const dec28Day = dec28.getUTCDay() || 7;
    return Math.ceil((dec28.getUTCDate() + dec28Day - 1) / 7);
    
    // OK: getIsoWeek を再利用する
    export function getIsoWeekCount(year: number): number {
      return getIsoWeek(new Date(Date.UTC(year, 11, 28))).week;
    }
  • 2015年(53週)・2020年(53週)・2024年(52週)等の境界ケースをテストすること。
  1. RSS の <pubDate> は RFC 2822 形式で new Date() でパース可能(#1171)
  • RSS 2.0 の <pubDate> は "Thu, 19 Jun 2026 13:00:00 +0900" 形式(RFC 2822)。new Date(dateStr) でそのまま解析でき、タイムゾーンオフセットも正しく処理される。
  • JST 日付文字列(YYYY-MM-DD)への変換は new Intl.DateTimeFormat('sv', { timeZone: 'Asia/Tokyo' }).format(date) を使うこと(既存の #595 参照)。
  • パース失敗時は isNaN(date.getTime()) で検出し undefined を返すことで、呼び出し側が pubDate ?? today のように安全にフォールバックできる。
  1. useState などの Hooks は条件分岐(早期 return)より前に呼ばなければならない(#1129)
  • React の rules-of-hooks ルール: Hooks はコンポーネントのトップレベルで、かつ条件分岐の前に呼ぶ必要がある。if (items.length === 0) return null; のような早期 return の後に useState を置くと ESLint の react-hooks/rules-of-hooks エラーになる。
  • セクションコンポーネントへの切り出しリファクタリングでは「データが空なら null を返す」早期 return を書きがちだが、内部 state が必要なコンポーネントは必ず Hooks を先に宣言してから早期 return すること:
    // NG: useState が if の後にある
    export function RelatedLivesSection({ relatedLives }: Props) {
      if (relatedLives.length === 0) return null; // ← 早期 return
      const [expandedTours, setExpandedTours] = useState<Set<string>>(...); // ← エラー
    }
    
    // OK: useState を先に置き、早期 return をその後に
    export function RelatedLivesSection({ relatedLives }: Props) {
      const latestTourKey = relatedLives.length > 0 ? relatedLives[0].tourId : null;
      const [expandedTours, setExpandedTours] = useState<Set<string>>(
        () => new Set(latestTourKey ? [latestTourKey] : []),
      );
      if (relatedLives.length === 0) return null; // ← Hooks の後なら OK
    }
  • 初期値の計算に props(relatedLives[0])を使う場合も、props への参照は useState の引数で行う形にするとルール違反を回避しながら初期値を安全に設定できる。
  • このルールは useState / useEffect / useRef 等すべての Hooks に適用される。
  1. React Flow Parent Node によるグルーピング表示の実装パターン(#1211)
  • React Flow でノードをグループ化表示するには Parent Node 機能を使う。グループノード(type: 'mapGroup' 等)を定義し、子ノードに parentId を指定することで視覚的な包含関係を表現できる。
  • ノード配列の並び順: グループノードを子ノードより前に配置しないと React Flow が警告を出す。[...groupNodes, ...childNodes] の順で setNodes に渡すこと。
  • expandParent: false: 子ノードに expandParent: false を設定しないと、子ノードの座標がグループ境界外にある場合に親ノードが自動拡張されてレイアウトが崩れる。
  • 子ノードの座標は親相対: parentId を持つノードの position はグループノード左上からの相対座標になる。絶対座標から変換するには relX = absX - groupX、relY = absY - groupY。
  • グループノードの pointerEvents: 'none': ラベルのみ表示するグループノードは style: { pointerEvents: 'none' } および selectable: false, draggable: false を設定してインタラクションを無効化する。
  • 混合型ノード配列: useNodesState<Node<TypeA | TypeB>> にすることで、異なるデータ型を持つノードを同一 state で管理できる。MiniMap の nodeColor コールバックにも Node<TypeA | TypeB> の型注釈が必要。
  1. React Flow の onNodeDragStop はネイティブ DOM イベントを受け取る(#1210)
  • onNodeDragStop のコールバック型は (event: MouseEvent | TouchEvent, node: Node) => void(ネイティブ DOM イベント)。React の合成イベント React.MouseEvent を型注釈すると tsc で型エラーになる。
  • イベントを使わない場合は _event: MouseEvent | TouchEvent と明示するか、引数を省略して TypeScript に推論させること。
  1. クライアントコンポーネントで URL パラメータを読む場合は window.location.search を使う(#1212)
  • Next.js App Router の useSearchParams() は Suspense 境界が必要なため、'use client' コンポーネントを <Suspense> でラップしない場合に導入コストが高い。
  • マウント時1回だけ読み取れれば十分な場合(初期値設定など)は useEffect 内で window.location.search を参照する方がシンプルで安全:
    useEffect(() => {
      const params = new URLSearchParams(window.location.search);
      const eventId = params.get('event');
      if (eventId) setSelectedEventId(eventId);
    }, []);
  • リアルタイムな URL 変化への追従(ブラウザバック等)が必要な場合は useSearchParams() + Suspense が適切。
  1. SQL集計で「ペアごとの最頻出ラベル」を算出するパターン(#1236)
  • 同一ペア(例: メンバー間の共演)に複数のラベル候補(番組名・イベント名など)が紐づく場合、「最も頻出したラベル」を代表値として1件に絞り込みたいケースがある。
  • PostgreSQL の集計だけでモード(最頻値)を求めるのは煩雑なため、GROUP BY (pair, label) でラベル別カウントを取得し、JS側でペアごとに集約する方が見通しがよい:
    -- ペア × ラベルごとの出現回数を取得(ペア単独の集計ではない)
    SELECT a, b, label, COUNT(*) AS count
    FROM ...
    GROUP BY a, b, label
    // ペアごとに label -> count の Map を積み上げ、weight はラベル問わず合算する
    const existing = map.get(key) ?? { from, to, weight: 0, labelCounts: new Map() };
    existing.weight += Number(count);
    existing.labelCounts.set(label, (existing.labelCounts.get(label) ?? 0) + Number(count));
  • 同数タイブレーク: 出現回数が同数の場合の選択結果が実行のたびに変わると再現性がなくなる。ラベルを文字列順にソートしてから走査し、厳密不等号(>)で更新することで「同数なら先に出た(=文字列順で最小の)ラベル」に決定的に収束する。
  • weight(エッジの太さ等、集計全体の量を表す値)と label(代表的な内訳)は独立した集計軸として扱うこと。ラベル別集計のために weight の算出方法を変える必要はない。
  1. ハイライト集合内で「自明・冗長な関係」を判定する完全グラフパターン(#1265)
  • フィルタ等で強調表示中のノード集合 H に対し、「H 全体に共通するため表示する意味がない関係」を判定したい場合、関係種別ごとに「H 内の全ペア(|H| × (|H|-1) / 2 通り)にその種別のエッジが存在するか(完全グラフか)」で判定すると、属性系(同期・同郷等)とペア系(師弟等)を区別せず一律に扱える。
  • 属性由来の関係(例: same-gen)は、生成時点で同一属性を持つ全ペアに機械的にエッジが張られているため、「H が同一属性を共有しているか」と「H 内で完全グラフか」は等価になる。個別に属性値を比較するロジックを別途持つ必要はない。
  • |H| = 2 の落とし穴: ペアが1組しかないため、その1組に何らかの関係が存在すれば、種別を問わず常に「完全グラフ」と判定される。軸(フィルタ条件)と無関係な関係種別であっても、H が2人だけの場合は道連れで抑制対象になる。これは意図した仕様(型・軸による特別扱いをしない一律ルール)であり、バグではない。3人以上の H で初めて「一部のペアにしかない関係は表示維持される」という判定の効果が現れる。
  • 実装は lib/member-map-utils.ts の getCommonRelationshipTypes(ペアキーを [a, b].sort().join('--') で正規化し、種別ごとに Set へ積み上げて必要数と比較)を参照。
  1. 並列エッジのオフセットは始点・終点ではなく中間の制御点だけをずらす(#1272)
  • 同一ノードペア間に複数のエッジがある場合に重なりを防ぐため横にずらす実装で、始点(sourceX/Y)と終点(targetX/Y)の両方に同じベクトルを加算すると、線全体がノードのハンドル位置から平行移動してしまい、始点・終点どちらもノードに接続していないように見える(「宙に浮いた」エッジになる)。
  • 同一ペア間に関係種別が1つしかない間は目立たないが、同一ペアに複数の関係種別(同期・同郷・同在籍等)が存在する場合にのみ発生するため、テストデータや目視確認で見落としやすい。加えて、他のエッジの陰に隠れて気づかれにくいこともある(#1265 でノイズとなる関係を非表示にした結果、この不具合を持つエッジだけが単独で目立つようになり発覚した)。
  • 正しい対処: 始点・終点は実際のハンドル位置に固定したまま、2次ベジェ曲線の制御点(始点と終点の中点 + 垂直オフセット)だけをずらす。M source Q (中点+オフセット) target の形にすることで、両端は必ずノードに接続されたまま曲線だけが横に膨らむ。
  • 実装は lib/member-map-utils.ts の getParallelEdgeControlPoint と components/GradientEdge.tsx を参照。オフセットが 0 の場合(並列エッジがない、またはインデックスが中央)は従来通り getBezierPath を使う。
  1. 共演エッジの label 表示は集計側(DB)とフロント側の2箇所を両方更新しないと反映されない(#1263)
  • scripts/workflow/sync-relationships.ts の集計クエリで member_relationships.label に値を格納しても、lib/member-map-utils.ts の getEdgeLabel() が参照する COAPPEARANCE_NAME_TYPES(Set)にその type を追加していない場合、画面には反映されず常に RELATIONSHIP_LABEL_MAP の固定文言が表示され続ける。
  • radio-coappearance/event-coappearance は既に COAPPEARANCE_NAME_TYPES に含まれていたが、live-coappearance は含まれておらず、DB側だけ label を算出する変更をしても無効化されたままになるところだった。新しい共演系エッジ種別を追加・変更する際は、集計クエリ(バックエンド)と getEdgeLabel の対象セット(フロントエンド)の両方を確認すること。
  1. member_relationships.label を type ごとに意味転用する際は getMemberGroupKey のグルーピングキーへの影響も確認する(#1269)
  • label は same-unit(ユニット名)・共演系(最多共演名)など type ごとに異なる意味で再利用されてきたが、mentor-student に direct/indirect の区別を持たせる際、getMemberGroupKey(lib/member-map-utils.ts)の relationship 軸グルーピングが matchingEdge.label ?? selectedRelType をグループキーとして使っていたため、そのままでは「師弟」1グループが direct/indirect の2グループに分裂し、RELATIONSHIP_LABEL_MAP にないラベルがそのまま英語表示されてしまうところだった。
  • 対処: mentor-student はグルーピングキーとして常に selectedRelType(type 固定)を返すよう特別扱いし、label の意味転用がグルーピング表示に波及しないようにした。
  • 教訓: 既存 type の label に新しい意味を持たせる変更をする際は、getEdgeLabel(エッジ表示)だけでなく getMemberGroupKey(グルーピングキー)・getFilteredMemberIds/getFilteredEdgeIds(フィルタリング、こちらは type 参照のため影響なし)など、label を参照する全箇所を洗い出すこと。
  1. 既存のグラデーションエッジに「別軸の表示モード」を追加する場合は source/target カラーを同一値にする(#1270)
  • GradientEdge(components/GradientEdge.tsx)は sourceColor→targetColor の linearGradient でエッジを描画する。関係性スコアなど「メンバーカラーとは無関係な指標」を色で表現したい場合、SVG 構造(<defs><linearGradient>)はそのまま流用し、sourceColor と targetColor に同じ値(スコアに応じた単色)を渡すだけで見た目上は単色エッジになる。エッジコンポーネントの分岐を増やさずに済む。
  • 太さも同様に、既存の getEdgeStrokeWidth(weight) はスコア(0〜100pt)にもそのまま適用できる(weight と totalScore は共に「大きいほど太くする」正の数値という同じ意味役割のため、変換式を共有できる)。
  • 表示モードの切り替えは既存の filter/group モードの state 分岐に混ぜ込まない: member-map/page.tsx は mode(filter/group)×axis の組み合わせで nodes/edges state を書き換える複雑な useEffect を持つが、スコアリングモードは「現在の edges に対して太さ・色だけを上書きする」独立した useMemo(displayEdges)として実装し、<ReactFlow edges={displayEdges}> に渡す。既存モードのどの分岐が edges を作っても後段で一律に上書きできるため、モード分岐を1つも触らずに新しい表示軸を追加できた。
  • スコア計算関数(getRelationshipScore)は「メンバー2人+関係性リスト+都道府県コード」を受け取る純粋関数として実装済(#1269)だったため、事前計算してDBに保存する batch/sync は不要で、画面表示時にその場で計算するだけで済んだ。バッチで事前計算するかその場で計算するかは、値が「表示都度変わりうるか」(例: 在籍重なりは now に依存し日々変わる)で判断するとよい。
  1. ControlAxis(lib/member-map-utils.ts)に新しい軸を追加する際に触れるべき関数一覧(#1266)
  • 軸を1つ追加すると、以下すべてに分岐を足す必要がある。1つでも漏らすと「軸ボタンは出るが選択肢が空」「フィルタは効くがグルーピングだけ効かない」といった中途半端な状態になる。
    • getAxisLabel(軸ボタンの日本語ラベル)
    • getAxisValueLabel(値セレクタの表示ラベル)
    • getAxisValues(値セレクタの選択肢一覧)
    • getFilteredMemberIds(フィルタモードのハイライト対象算出)
    • getMemberGroupKey(グルーピングモードのグループキー算出)
    • buildGroupInfos(グループラベル・ソート順)
    • MapControlPanel の AXES 配列(軸ボタンの表示自体)
  • 一方 getFilteredEdgeIds は axis === 'relationship' のときだけ専用分岐を持ち、それ以外の軸は「ハイライト済メンバー2人を結ぶエッジを表示する」という汎用ロジック(highlightedMemberIds ベース)に自動的に乗るため、新規軸追加時は基本的に変更不要。
  • 軸のラベルにメンバー名など動的データが必要な場合(例: リーダー名を使った「〇〇チルドレン」表示)、getAxisValueLabel は固定の RELATIONSHIP_LABEL_MAP のような定数参照だけでは対応できない。members: Member[] = [] のような省略可能引数を追加して後方互換を保ちつつ、呼び出し元の MapControlPanel にも members prop を新設して橋渡しする。
  1. メンバー起因の追加データ(愛称等)を新設する際、ローカルJSONの静的importで済ませてはいけない(#1266)
  • 「デプロイなしでコンテンツを更新したい」データ(メンバーカラー・リーダー世代番号・教育係等)は、このアプリでは「ローカルJSON(data/*.json、パッチスクリプトへの入力) → パッチスクリプトが Blob の members.json にマージ → ランタイムは /api/members 経由でBlobから取得」という一貫したパターンを取っている(scripts/patch/patch-leader-generations.ts・patch-instructor.ts 等)。
  • リーダーの愛称機能を実装した際、当初 lib/member-map-utils.ts から data/leader-nicknames.json を直接 import(resolveJsonModule 利用)する実装にしてしまった。これは静的importのためNext.jsのビルド時にバンドルされ、members.json(Blob経由でランタイム取得)と違ってJSONを編集しただけでは本番に反映されず、コミット+デプロイが必要になる。既存の「コンテンツはBlob、コードはデプロイ」という設計原則に反していた。
  • 対処: types/member.ts の Member スキーマに leaderNickname: z.string().optional() を追加し、scripts/patch/patch-leader-nicknames.ts(patch-leader-generations.ts と同型)でBlobにマージする方式に変更。lib/member-map-utils.ts 側は静的importをやめ、member.leaderNickname を直接参照するだけになった。
  • 教訓: 「メンバーに紐づく、頻繁に変わりうる/ユーザーが手動で用意するデータ」を追加する際は、真っ先に「Blobに置くべきか」を検討する。ローカル data/*.json を作る前に、既存の scripts/patch/ 配下に類似パターンがないか確認すること。
  1. 標準競技順位方式(同率は同順位、次順位はタイ人数分スキップ)の実装パターン(#1282)
  • 「上位N位を全員表示する」機能(同率タイがいる場合は同順位のメンバー全員を含める)を実装する際、降順ソート後に「直前の値と異なる場合のみ rank = index + 1 を更新し、同値ならそのまま前の rank を引き継ぐ」というループで標準競技順位(1224方式)を算出できる。lib/member-map-utils.ts の buildMemberRelationshipRanking を参照。
  • タイの並び順は getScoreColor 等と同様、値が同じ場合のタイブレークキーを明示することで再実行しても順序が変わらないようにした(既存の同数タイブレーク方針と同じ考え方)。当初は memberId の文字列順だったが、意味のある表示順ではなかったため #1291 でグループ優先順位・加入日/卒業日ベースの基準に置き換えた(76番を参照)。
  • TypeScriptの余剰プロパティチェックの回避パターン: RelationshipScore(total 持ち)と MemberGroupRadarScore(pairCount 持ち)のように、共通の4軸フィールドだけを受け取る型(RadarAxisScores)を関数の引数型にすると、両方の型を構造的に受け入れられて実装の重複を避けられる。ただしテストでオブジェクトリテラルを直接渡すと「余剰プロパティチェック」に引っかかり型エラーになるため、一度変数に代入してから渡すことでチェックを回避する(lib/member-map-utils.test.ts の toNormalizedRadarValues テストを参照)。
  1. getRelationshipScore が絡む「総合スコアは同値だが日付が異なる」テストケースは OVERLAP_MONTHS_CAP(10年)を超える差にして overlap 軸を両方 40pt に飽和させる(#1291)
  • buildMemberRelationshipRanking のタイブレーク順(グループ優先度→joinDate/gradDate)をテストする際、素朴に joinDate/gradDate だけを変えると getOverlapScore(在籍期間の重複月数ベース)まで変化し、総合スコアがタイにならず意図したテストにならない。
  • 対策: 基準メンバーとの在籍重複期間が OVERLAP_MONTHS_CAP(lib/member-map-utils.ts 内、120ヶ月=10年)を超えるように日付を離しておけば、overlap 軸は両方とも上限の40ptに飽和するため、joinDate/gradDate の値だけを自由に変えつつ総合スコアのタイを再現できる。color・birthplace は同一値にして他の2軸も揃える。
  1. 複数テーブルへの CSV 一括登録は「計画を組み立てる純粋関数」と「DB I/O」を分離し、書き込み前に全件解決できるか検証する(#1305)
  • scripts/console/register-tour-lives.ts のように、CSV から複数テーブル(venues・tours・lives)へ新規採番しながら登録するスクリプトは、DB アクセスを含まない buildRegistrationPlan() のような純粋関数に採番ロジックを切り出すと、実DBなしで採番パターン(連番の継続・都道府県ごとの独立採番・重複スキップ)を網羅的にテストできる。
  • 会場名の突き合わせのように「一部のデータが解決できないと後続の登録ができない」ケースは、書き込み($transaction)の前に全件を解決を試み、1件でも解決できなければ何も書き込まずにエラーで停止する(未解決名を列挙)。部分的に書き込んでしまうと再実行時の整合性判断が難しくなるため。
  1. メンバー単位でSNS収集するスクリプトを新設・改修する際は shouldSyncOG() の適用漏れがないか個別に確認すること(#1297)
  • OG_SYNC_WEEKDAYS(scripts/lib/og-sync.ts の shouldSyncOG())によるOGメンバーの収集頻度制限は、対象スクリプトごとに個別実装されており、共通の実行エントリポイントで一括適用される仕組みではない。
  • sync-status.ts(ブログ・Web検索)と collect-youtube-og.ts(YouTube OG)には実装されていたが、collect-instagram-posts.ts(Instagram)には適用されておらず、OGメンバーのInstagramが毎日収集され続けるバグになっていた。
  • メンバー単位でSNSアカウントを走査する新規収集スクリプトを追加する際は、既存の類似スクリプト(sync-status.ts 等)を機械的にコピーするのではなく、「Active専用に処理を絞ってよいか」「OGも対象なら shouldSyncOG() で絞り込む必要があるか」を都度明示的に判断すること。
  • フィルタ実装は members.filter((m) => m.status === 'Active' || syncOG) のように「Active + syncOG時のみOG」という形にすると、shouldSyncOG() の判定結果(boolean)を引数として渡す純粋関数に切り出せ、曜日ロジック(Date 依存)とメンバー選別ロジックを分離してテストしやすくなる。
  • Prisma の配列形式 $transaction([op1, op2, ...])(コールバック形式 $transaction(async (tx) => {...}) ではない)は vi.fn((ops) => Promise.all(ops)) で素直にモックできる。配列の各要素は呼び出し時点で作られた Prisma Promise であり、モックした各メソッド(create/upsert/createMany 等)の戻り値をそのまま Promise.all に渡せば良い。
  1. 正規表現でCDATAを含むXML/RSSをパースする際は、ユニットテストのフィクスチャだけでなく実データで構造を確認すること(#1328)
  • extractRssField のCDATA抽出用正規表現 <${tag}[^>]*><!\[CDATA\[... は「タグ直後に空白なしで <![CDATA[ が続く」ことを前提にしていたが、Ameba の実際のRSS(https://rssblog.ameba.jp/{id}/rss20.xml)は <description>\n<![CDATA[... のようにタグと <![CDATA[/]]> の間に改行を挟む構造だった。ユニットテストのXMLフィクスチャは空白なしで書かれていたため、この乖離が長期間検知されなかった。
  • この不一致によりCDATA抽出が常に失敗し、素朴なタグ除去(replace(/<[^>]+>/g, ''))へフォールバックしていた。<![CDATA[ という文字列が閉じられていない偽のHTMLタグとして扱われ、そこから本文中で最初に出現する > までが丸ごと削除される。この「最初の > の位置」が投稿ごとに異なるため、ほぼ空になるケースと、埋め込みリンクカードの断片が無空白で連結された巨大な文字列が残るケースの両方が発生し、症状の見え方が投稿ごとにバラバラで原因特定を難しくしていた。
  • 対策: <${tag}[^>]*>\s*<!\[CDATA\[...\]\]>\s*<\/${tag}> のようにタグ境界の空白・改行を許容する。実データ(curl等)で構造を確認してから正規表現を確定させ、テストフィクスチャにも実データと同じ空白パターンを含めることで再発を検知できるようにする。
  • あわせて、外部ブログの本文(description)をそのまま content に保存するのは著作権上のリスクがあるため、content にはタイトルのみを格納する設計に変更した。本文はグループブログの投稿者判定(メンバー名が含まれるか)にのみ使用し、出力には一切含めない。
  1. グループブログの投稿者判定は「本文にメンバー名が含まれるか」ではなく「タイトルに含まれるか」で行うこと(#1328)
  • findAllNewMemberAmebaItems / findMemberAmebaItem は、タイトル一致がなければ本文(description)一致にフォールバックしていたが、これは個人ブログ(1人専用)を前提にした設計だった。
  • グループブログ(複数メンバー共有)では、あるメンバーが自分の投稿内で別メンバーに言及する(例: 「バースデーイベントに岡村ほまれちゃんが来てくれました」)ことが頻繁にあり、本文一致だけで判定すると言及されただけの他メンバーにもその投稿が誤って帰属してしまう。実データで、2人の投稿が入れ替わって格納されるケースや、1つの投稿が4人分の履歴に重複して格納されるケースが見つかった。
  • 対策: 呼び出し元(buildAmebaCache)が既に持っている isGroupBlog(同一Ameba IDを共有するメンバーが2人以上か)を findAllNewMemberAmebaItems / findMemberAmebaItem に渡し、グループブログの場合は本文一致によるフォールバックを行わない(タイトル一致のみ)。個人ブログは誤帰属のリスクがないため従来通り本文一致も許容する。
  • 既存データの補正では、同一のAmeba投稿URLが複数メンバーの statusHistory に重複している箇所を検出し、タイトルに自分の名前があるメンバーの履歴にのみ残すパッチスクリプトで対応した。
  1. 配列から複数の要素を削除する際、複数グループにまたがって同じ配列のインデックスを事前計算していると、先に行った削除で後続のインデックスがずれて誤動作する
  • member.statusHistory から「重複URLグループごとに削除対象を判定する」処理で、各グループの対象を { member, index }(配列インデックス)として事前に収集し、グループを順番に処理しながら array.splice(index, 1) していたところ、同じ member が複数のグループに登場するケースで、先に処理したグループの削除によって配列が縮み、後続グループで使う index が実際とは別の要素を指してしまうバグが発生した(実データのdry-run結果で、本来1人だけ一致するはずの判定が誤って2人一致と表示され発覚)。
  • 対策: インデックスではなく**削除対象の要素そのもの(オブジェクト参照)**を Set に集めておき、全グループの判定が終わった後に array.filter(e => !toRemove.has(e)) でまとめて除外する。参照の同一性は配列の位置に依存しないため、処理順序に関わらず正しく動作する。
  • この種のバグは小規模なユニットテスト(削除対象が1グループのみ)では再現せず、「同じ要素が複数グループに重複して登場する」ケースを明示的にテストして初めて検出できる。
  1. twitterapi.io の has_next_page: false は「投稿履歴の終端」を意味しない場合がある(#1331)
  • X(旧Twitter)のタイムライン取得系APIには、直近3,200件付近までしか遡れないプラットフォーム側の恒久的な上限がある。twitterapi.io の last_tweets はこれをラップしているため、上限に到達すると has_next_page: false かつ 0件で返ってくることがあり、これは「アカウントの投稿がそこで尽きた」ことを意味しない。
  • モーニング女学院(@morning1422)の遡及取得で、終端エピソード(483時間目・2021-07-10)の直前週(2021-07-03)が丸ごと欠落しており、かつエピソード番号から逆算した放送開始時期(2012年頃)よりはるかに新しい時点で「完了」扱いになっていたことから発覚した。
  • 対策: この上限はコード側では回避不可(別のデータソースが必要)。scripts/lib/twitterapi-client.ts の pageCount === 0 判定・scripts/workflow/collect-onair-morning.ts のカーソル done フラグには、「取得上限到達」であって「履歴完了」ではない旨をコメント・ログメッセージで明示すること。
  1. String[] フィールドが未登録(空配列)のレコードを hasSome で絞り込むと除外される問題は、書き込みパイプラインより先に読み取り側フォールバックを検討する(#1326)
  • events.members(String[])に memberLives 未登録のライブが members: [] で登録されており、hasSome: selectedMemberIds によるメンバー絞り込みクエリではこれらが常に除外され、「新規登録した公演が年表に一切表示されない」バグになっていた。
  • 当初は member_lives へ在籍期間ベースで自動書き込みするパイプラインの新設(要設計)を検討したが、events レコード自体は既に存在していたため、読み取り側のクエリ条件を緩めるだけで解消できることが判明し、書き込み側の設計は不要になった。
  • パターン: Prisma の where に OR: [{ members: { hasSome: selectedMemberIds } }, { type: "live", members: { isEmpty: true } }, ...] を指定していったん該当レコードを取得し、members が空だった行だけをアプリ側でフォールバック判定する(lib/timeline-utils.ts の resolveEventMembers/withResolvedMembers)。isEmpty フォールバックを type で絞らないと、無関係な種別まで巻き込んでしまう点に注意。
  • 表示用データも同時に補完する: フィルタ判定だけでなく members 配列自体を導出結果に差し替えることで、EventCard のアバター表示・地図リンク表示(event.members.length > 0 条件)も正しく機能するようになる。単に絞り込み条件だけ緩めると、イベントは表示されてもメンバー情報が空のカードになってしまう。
  • 種別ごとに最適な導出ロジックが異なる点に注意(当初 live のみで実装し、レビューで festival・release も同根の問題と指摘されて拡張した):
    • live・festival: 個別の出演者データを持たない団体イベントとして扱い、開催日時点の在籍期間(joinDate〜gradDate、isMemberActiveOnDate)で判定する。
    • release(モーニング娘。本体): 単純な在籍期間ではなく、既存の lib/member-release-linker.ts(linkMemberToReleases)が持つ「加入翌月以降で最初のシングル発売日」を実質基準日とするロジックを再利用する。加入直後の制作済リリースへの誤紐づけを避けるための既存ロジックがあるのに、簡易な在籍期間判定で代替すると精度が落ちる。
    • release(ユニットによるリリース): 在籍期間ではなく固定メンバー(unit.memberIds)で判定する(lib/unit-helpers.ts の getSubunitMembersByReleaseId)。ユニットは全体の在籍期間と無関係な別ロジックのため、同じ関数に混ぜず種別で分岐する。
  • 教訓: 「表示条件を緩める」対応をする際、対象を最初に気づいた種別(今回は live)だけに限定しがちだが、同じ empty-array 設計(=「個別登録の手間を省くための空配列」)を持つ他の種別がないか横展開の要否を確認すること。判定ロジックの精度が種別ごとに異なることは、対応範囲を狭める理由にはならない。
  1. 「ループ内はチェックあり・ループ後の単発処理はチェックなし」という非対称な重複排除ガードは見落とされやすい(#1329)
  • sync-status.ts の syncMembers() は、sync 間の複数新着ブログ投稿を古い順に蓄積するループ(#1167)の中では shouldSkipAccumulation(olderItem.content, member.statusHistory) で都度最新化された履歴と重複チェックしていたが、ループ終了後に呼ばれる「最新投稿」の accumulateStatus だけはこのチェックがなく無条件実行だった。
  • ループ側が参照する新着配列(newAmebaItems)の末尾2件が偶然にも同一 content(同一URL・同一投稿)だった場合、ループ側で1件目が蓄積された直後、ループ後の無条件呼び出しで内容の同じ2件目も蓄積されてしまい、statusHistory に同一URL・同一日付・同一contentの隣接エントリが2件残る。トリガーは「RSSフィードが同一投稿を複数 <item> で重複掲載する」「メンバー名一致フィルタが同一投稿を複数回拾う」等、外部データ側の揺れで発生しうるため、通常の単発ケースのテストでは再現しない。
  • 対策: ループ内外で同じ蓄積関数(accumulateStatus)を呼ぶ箇所は、ガード(shouldSkipAccumulation)も同じ条件・同じ最新状態(member.statusHistory、ループによる変更を反映済のもの)を参照して対称に適用すること。「ループの中だけ気をつける」設計は、ループ外の単発呼び出しがガード漏れの温床になる。
  • データ補正は既存の scripts/patch/patch-ameba-misattributed-status.ts 等と同じく、隣接エントリを走査して重複分を除去する冪等なパッチスクリプト(scripts/patch/patch-ameba-duplicate-status.ts)で対応した(#81 の教訓に従い、インデックスではなく1パスの filter 再構築で実装)。
  1. ローカルシード JSON を Blob 参照に置き換えると、Blob 未初期化時の「初回起動」経路が失われる(#1337)
  • sync-discography.ts の loadUnits() は元々 data/units.json(リポジトリにコミットされた固定シード)を読んでいたが、データディレクトリ整理で getUnitsFromBlob()(UNITS_BLOB_URL からの fetch)に切り替えた。
  • UNITS_BLOB_URL が未設定・未初期化の環境では getUnitsFromBlob() が空配列を返し、extractUniqueMbids([]) が空になって syncDiscography() が「MBID を持つユニットが見つかりません」で即座に処理を中断する。ローカルシードのときは常に非空だったため、この経路は初めて顕在化した。
  • 本番では UNITS_BLOB_URL が既にシークレット登録済で Blob 側に実データがあるため実害はないが、「参照元をローカルシードから外部ストア(Blob/DB)に切り替える」変更は、外部ストアが空の状態からの初回ブートストラップ経路を必ず失うことに注意。ブートストラップが必要な場合は、シードを一度だけ流し込む別スクリプト・手順を用意すること。
  • テスト側もこの変更に合わせて vi.mock('../../lib/blob', ...) で複数 MBID を持つ固定フィクスチャを返すようにした。以前は実ファイル(data/units.json)の内容に暗黙依存していた(ファイル内容が変わるとテストの前提が崩れる、気づきにくい結合だった)。
  1. 同じイベントIDを複数箇所でDOM id に使うと、コンポーネントの内部再利用箇所と衝突して重複IDになる(#1323)
  • タイムライン画面で「今日の位置へ自動スクロール」を実装する際、ツアーグループの外側ラッパー(app/timeline/page.tsx の liveGroupWrapper)に id={item.events[0].id} を付与した。
  • LiveTourGroup は内部で firstGroup[0](= sorted[0])を使って EventCard を描画しており、item.events[0] と同じイベントIDになるケースがほとんど(yearEvents は日付昇順で取得しているため)。もし EventCard 側にも同じ規則で id={event.id} を付けていたら、外側ラッパーと内側の EventCard の <article> が同一の id を持つ「重複ID」状態になり、document.getElementById の挙動がブラウザ実装依存になる。
  • 対策: EventCard の id prop はオプションにし、LiveTourGroup 内部で呼び出す箇所には渡さない(ラッパー側の id だけをスクロール先アンカーにする)。単体(kind: "single")で直接描画する EventCard にのみ id={item.event.id} を渡す。
  • 一般化: あるコンポーネントが内部で子コンポーネントを複数回・別文脈で再利用している場合、その子コンポーネントに一律で id を追加するのは危険。「外側のラッパーに付ける」か「本当に一意な描画箇所にだけ渡す」かを、再利用パターンを先に確認してから決めること。
  1. scripts/patch/*.ts の revalidateCache 呼び出しは Blob 経由データにのみ必要。Prisma(Neon)直接読み込みのページには不要(#1292)

    • #11(#1286/#1295)で確立した「手動パッチスクリプト実行後に revalidateCache を呼ぶ」パターンは、members.json のような Vercel Blob を Next.js の Data Cache(fetch(..., { next: { revalidate } }))経由で読むデータが対象。
    • radio_onair_songs 等 Neon の DB テーブルを直接読むページ(app/radio/episodes/[episode_id]/page.tsx・app/songs/[id]/page.tsx・app/timeline/page.tsx)は全て export const dynamic = 'force-dynamic' でリクエストごとに Prisma を直接叩いており、Next.js のキャッシュを経由しない。そのため DB を直接更新するパッチスクリプトに revalidateCache を追加しても意味がなく、既存の collect-onair-morning.ts・register-manual-radio-episodes.ts も呼んでいない。
  2. 「基準日を算出する絞り込み条件」と「表示するかどうかを判定する絞り込み条件」は必ず同じ集合を参照すること(#1356)

    • components/NewPostsSection.tsx の Ameba 新着セクションは、基準日(amebaDataLastUpdated)を「sources に ameblo.jp ドメインの URL を含むメンバー」の lastUpdated 最大値で算出し、表示可否は別途「officialSns に有効な Ameba ブログが登録されているか(hasOfficialAmeba)」で判定していた。この2つの絞り込み条件が異なる集合を参照していたため、officialSns 未登録メンバーの Exa 検索由来の誤検出ソース(本人と無関係な ameblo.jp URL)がたまたま最新日付を持つと、基準日だけがそのメンバーの日付にズレる。一方、公式ブログ登録済メンバーの実際の投稿日はズレた基準日と一致しなくなり、isNew && hasOfficialAmeba を同時に満たすメンバーが0人になって Ameba セクション全体が非表示になった。
    • この問題は #1175 で「全メンバーの最大値を基準にすると Exa 検索由来の更新日が混入する」ことへの対処として一度修正されていたが、その際の絞り込み条件(sources に ameblo.jp を含むか)が表示条件(hasOfficialAmeba)より緩く、同種の問題が形を変えて再発した。
    • 対策: 基準日算出のフィルタ条件に hasOfficialAmeba を追加し、表示条件と同じ集合(公式 Ameba ブログ登録済メンバー)に限定した。「新着の基準日」と「新着として表示するか」を別々のロジックで計算する場合は、両者が同じメンバー集合を対象にしているか実装時に必ず突き合わせること。 片方だけ条件を絞ると、絞られなかった側の外れ値がもう片方の判定を道連れにして機能全体を沈黙させる(0件表示・エラーなしで気づきにくい)。
    • 判断基準: 新規パッチスクリプトを書く際は、更新対象データを読むページが Blob 経由(Data Cache あり)か Prisma 直接読み込み(force-dynamic でキャッシュなし)かを確認し、前者の場合のみ revalidateCache を追加する。
  3. data/inputs/manual-events.json の date に日時を1文字列で詰め込むと、parseDateString の正規表現に一致せず該当イベントがログにしか残らず静かに消える(#1381)

    • sync-events.ts の parseDateString は YYYY-MM-DD / YYYY-MM / YYYY の3パターンのみ対応しており、一致しない文字列は null を返す。呼び出し元の convertManualEvents/convertReleasesToEvents はパース失敗イベントを flatMap で除外するだけで、logger.warn の1行以外に失敗の痕跡が残らない。
    • Event テーブルに time カラムがなかった頃、時刻を持たせたい手動イベントは "date": "2026-08-20 16:55" のように date に時刻を直接埋め込んでいた(morning-status-app 独自の運用上の誤用)。この形式は上記どの正規表現にも一致しないため、syncEvents() 実行のたびに該当イベントが created/updated カウントに含まれず、タイムラインに一切表示されないまま気づかれにくい状態が続いていた。
    • 対策: #1381 で Event.time(DateTime? @db.Time(0))カラムと ManualEventSchema.time("HH:MM" 文字列、任意)を新設し、date は常に YYYY-MM-DD のみを持つよう分離した。date に時刻らしき文字列(空白区切りの HH:MM 等)が含まれていないかは、bun run scripts/workflow/sync-events.ts 実行時の [WARN] ...のパースに失敗 ログで検知できるため、手動データ追加後は同スクリプトの WARN 行を確認する運用を徹底すること。
  4. git mv を伴うデータディレクトリ整理(#1337)で、移動先ファイルを参照するスクリプト側のパス定数更新が1箇所だけ漏れていた(#1394)

    • data/manual-radio-episodes.json → data/inputs/manual-radio-episodes.json への移動(#1337「Dataディレクトリの整理」コミット)で、同様の入力ファイルを持つ他5スクリプト(sync-discography.ts・seed-member-relationships.ts・patch-member-nicknames.ts・patch-member-colors.ts・patch-member-joining-routes.ts)は全て参照パスが data/inputs/... に追随して更新されていたが、register-manual-radio-episodes.ts の MANUAL_RADIO_EPISODES_FILE_PATH だけ旧パス(data/manual-radio-episodes.json)のまま取り残されていた。
    • 症状が気づかれにくかった理由: このスクリプトの loadManualRadioEpisodes() は「ファイル未配置=一時データのため正常系」という設計(#1274)のため、存在しないパスを読もうとしても logger.warn の1行が出るだけでエラーにも異常終了にもならない。CI・型チェック・ユニットテスト(readFileSync をモックしているため実ファイルの有無を検証できない)のいずれでも検出されず、機能そのものが約1週間サイレントに停止していた。
    • 教訓: git mv/ファイル移動を伴うリファクタリングでは、同じパターン(同一コミットで一括移動された複数ファイル)を持つ他の参照元スクリプトを横断的に grep して、更新漏れがないか機械的に確認すること。「ファイル未配置は正常系」として握りつぶす設計のスクリプトは、パス誤りとファイル未配置の実質的な違いがログ以外に現れないため、リファクタリング時の検証観点として特に意識する必要がある。
  5. Instagramプロフィールグリッド内のReel投稿リンクはユーザー名プレフィックス付きで出現する(/{username}/reel/{id}/)。新規Playwrightスクリプトも node 実行なら Windows で直接動作する(#1373)

    • isReelHref はグリッド内リンクを /reel/{id}/(先頭が reel)形式と仮定していたが、実際のDOM(a[href*="/reel/"])は /{username}/reel/{id}/ というユーザー名プレフィックス付きの相対パスだった。パスの先頭セグメントのみを見る判定(pathname.split('/')[0])は常に username を返すため、Reel投稿が1件も検出できていなかった。対策: パスの先頭に限定せず、任意セグメントに reel/reels が含まれるかで判定する(extractPostId の findIndex と同じ考え方に統一)。Instagramのグリッド内リンクを扱う実装(新規追加時含む)は、この形式差を前提にすること。
    • node 実行によるWindows制約の回避範囲の拡張: テスト手法#24(Windows + Bun で chromium.launch() がハング、node なら動作する)は「手動検証用の .mjs スクリプト」の文脈で記録されていたが、Node 24(node --version で確認)は .ts ファイルもビルド不要でそのまま実行できる(型ストリッピング内蔵)ため、scripts/patch/*.ts のような実際のPlaywright依存パッチスクリプトも node で実行すればWindows上で動作する。以前は「Playwrightが必要な処理はmacOS必須」と誤って結論づけたが、bun ではなく node で実行する前提を検証してから制約を判断すること。訂正(#1400、詳細は本書#93): ここでの検証は relative import を持たない単純なスクリプトのみで確認したもので、patch-instagram-reel-flag.ts のように他モジュールを import するスクリプトでは node scripts/patch/xxx.ts の直接実行だけでは不十分(拡張子なしimportが解決できない)と判明した。
    • 教訓: Playwrightスクリプトが特定OSで動かないという既存の知見に当たった場合、まず自分でその制約を再現・再検証してから対応方針を決める(過去の知見が別のランタイム(Bun vs Node)を暗黙の前提にしていることがあるため、書かれている条件を字面通りに一般化しない)。
  6. 外部サービスを操作するCLIの引数は、そのサービス自身の識別子(汎用)を受け付け、アプリ内部の管理用ID(局所的)は内部で解決する(#1400)

    • patch-instagram-reel-flag.ts(#1373)の初期実装は --id にメンバーの内部ID・公式アカウントの内部ID(ハイフン区切り、morningmusume-official 等)を要求し、Blob格納パス(instagram/{id}/posts.json)にもそのまま使っていた。しかし操作者が実際に把握しているのはInstagramの実アカウント名(アンダースコア区切り、morningmusume_official 等)であり、表記差異により --id morningmusume_official を渡すと「見つかりません」エラーで失敗した。
    • 原因の本質: 「操作者がドキュメントを読んでいない/内部IDを知らない」という認知面の問題ではなく、識別子の汎用性の扱いが逆転している設計原則の問題。Instagramアカウント名はアプリ外部(Instagram自体)に存在し誰でも参照・検証できる汎用的な識別子であり、内部ID(メンバーID・公式アカウントID)はこのアプリのBlob格納パスにしか意味を持たない局所的な識別子。外部システムを操作するインターフェースは前者を入力として受け取り、後者への変換は実装側で隠蔽すべきで、逆転させると「内部の管理用キーを知らないと外部システムを操作できない」構造になり、ドキュメントの正確性とは無関係に誤操作を誘発し続ける。
    • 対策: resolveUsername を「内部IDから検索」ではなく「Instagramアカウント名からofficialSns/OFFICIAL_INSTAGRAM_ACCOUNTSのinstagramUrlを逆引き」する設計に変更し、Blob格納用の内部ID(storageId)は逆引き結果から解決するようにした。また、officialSnsに該当URLはあるがactive: false(登録済だが収集対象外)のケースを、完全な未登録(not-found)と区別する inactive 状態も設けた(該当なしとだけ表示すると事実と異なるため)。
    • 教訓: CLIツールが「内部データモデルのID」と「外部サービス自身の識別子」の両方を扱う場合、引数として要求すべきは常に外部サービス側の識別子。内部IDを引数に要求する設計は、実装者にとっての実装しやすさ(DBキーとの直接一致)を操作者の使いやすさより優先してしまっている兆候であり、レビュー時に「この引数の値を、操作者は他の場所(このアプリ以外)で確認できるか」を確認する観点として持つ。
  7. patch-instagram-reel-flag.ts を実際に node 実行すると、項目91で「動作する」とした前提が崩れ3つの問題が連鎖して起動すらできなかった(#1400)

    • 問題1: 拡張子なし相対importが解決できない: tsconfig.json の moduleResolution: "bundler" を前提に from '../../types/instagram' のような拡張子なしimportを使っているが、プレーンな node のESMローダーはこれを解決できず ERR_MODULE_NOT_FOUND で起動時に落ちる。項目91の検証はrelative importを持たない単純なスクリプトのみで行われており、複数モジュールを跨ぐ実際のパッチスクリプトでは再現しなかった。
    • 対策1: tsx(devDependencies に追加)を node --import tsx scripts/patch/xxx.ts の形でローダーとして噛ませることで、ソースコードのimport文を一切変更せずに拡張子省略解決を行わせる。プロセス起動自体は node バイナリが行うため、項目24のBunハング問題も回避できる。
    • 問題2: .env.local が自動読み込みされない: bun は .env.local を自動読み込みするが、node は読み込まないため BLOB_READ_WRITE_TOKEN 等が全て未設定のまま実行される(エラーメッセージで気づける)。
    • 対策2: node --env-file=.env.local を付与する(Node 20.6+で利用可能)。
    • 問題3: import.meta.main が tsx ローダー経由では undefined になる: Bun・ネイティブ node script.ts ではエントリポイント判定として機能するが、node --import tsx script.ts 経由だとローダーがエントリポイント情報を伝播せず undefined になり、if (import.meta.main) のガードが常に false 扱いになってメイン処理が黙って実行されない(エラーも出ず exit 0 で終了するため気づきにくい)。
    • 対策3: process.argv[1] === fileURLToPath(import.meta.url) という移植性のある判定に置き換える。bun・ネイティブnode・node --import tsx の3パターン全てで正しく動作することを確認済。
    • 教訓: 「node で動く」という検証は、実行方法(プレーンnode / ローダー経由)・環境変数読み込み・エントリポイント判定という複数のレイヤーそれぞれで成立を確認しないと、一部レイヤーだけ検証して「動作する」と一般化してしまう。特にBun向けに書かれたコード(import.meta.main 等のBun-optimized API)をNodeで動かす場合は、そのAPIがローダー経由でも同じ挙動を保証するかまで確認すること。
  8. 同じ対象データに対する判定ロジックが複数箇所に分散していないか、新規実装前に確認する(#1403)

    • デイリーダイジェスト集計用の buildTikTokSummaryEntry(scripts/workflow/collect-tiktok-posts.ts)は、UI表示側で使われている正しいメンバー判定関数 attributeTikTokPostToMembers(lib/tiktok-attribution.ts、キャプション内の複数メンバー名をすべて拾う実装)とは別に、caption.includes(member.name) で最初にマッチしたメンバーのみを採用し break する簡易な自前マッチを独自に実装していた。1投稿のキャプションに複数メンバー名が含まれる場合、2人目以降が新着通知の集計から漏れるバグになっていた。
    • 対策: buildTikTokSummaryEntry を attributeTikTokPostToMembers の呼び出しに置き換え、判定ロジックを一本化した。
    • 教訓: 集計・表示用の新しいロジックを書く前に、同じ対象データ(この場合はTikTok投稿キャプションのメンバー名マッチング)に対する既存の判定関数がないか確認すること。片方だけ改修すると仕様がズレたまま気づかれにくい(実装#9「SNS判定は判定ロジックをlib/sns.tsに集約する」と同種の原則)。
  9. 「新着」判定は投稿日時(イベント発生日時)ではなく収集日時(バッチが実際に取得した日時)を基準にする(#1408)

    • Ameba(components/NewPostsSection.tsxのAmebaウィンドウ判定)・TikTok(同ファイルのrecentTikTokDate)・YouTube(lib/youtube.tsのgetRecentYoutubePosts)の3セクションが、それぞれ独立した実装であるにもかかわらず同じ誤りを持っていた。投稿自身の日時(RSSのpubDate・TikTokのcreatedAt・YouTubeのpublishedAt)を新着ウィンドウ・当日判定の基準にしていたため、RSS反映やAPI取得の遅延で「投稿されたのは前日以前だが収集は当日」というケースが判定から漏れていた。
    • 対策: 3セクションとも、投稿日時ではなく収集日時(capturedAt、Ameba用はStatusEntryに新規追加)を基準に変更した。Instagram(scripts/workflow/collect-instagram-posts.tsの当日マージ)は元々capturedAt基準だったため対象外。
    • 教訓: 「直近の新着」を提示する機能を新規実装する際、対象データが「発生した日時」と「取得された日時」の2種類の日時を持つ場合、判定基準に使うべきは常に後者(収集日時)。前者を使うと、収集タイミングの揺らぎ・API/RSS反映遅延によって「収集はしたのに表示されない」不整合が必ず発生する。同種の新着表示機能を追加する際は、まずこの2種類の日時が対象データに存在するかを確認すること。
    • 実装時の副次的な罠: TikTokの投稿一覧(allTikTokPosts)は投稿日時(createdAt)降順でソートされていたため、「配列の[0]が収集日時(capturedAt)の最大値である」という前提が成立しなかった(別フィールドでソートされた配列の[0]を、そのフィールドとは異なるフィールドの最大値として扱わないこと)。reduceで全件からcapturedAtの最大値を求めるよう修正した。
  10. 過去エントリへの capturedAt バックフィルは「スクリプト実行時刻」ではなく「実際に収集が完了した時刻」を使う(#1414)

    • #95(capturedAt 導入)のマージ前に蓄積された Ameba 投稿へ capturedAt を手動設定するパッチスクリプト backfill-ameba-captured-at.ts の初版は、new Date().toISOString()(スクリプト実行時刻)を capturedAt として設定していた。新着ウィンドウは [previousAutoSyncedAt, lastSyncedAt](ameba/sync-status.json)で、lastSyncedAt は「最後に定期実行が完了した時刻」を指す。スクリプトの実行タイミングが定期実行より後になると、設定した capturedAt がウィンドウの終端より後ろになり、対象エントリが新着表示から除外される。
    • 対策: capturedAt には、実際にその投稿を収集した定期実行(GitHub Actions run)の完了時刻(=当時の lastSyncedAt 書き出し値)を固定値として設定するよう修正した。ウィンドウ側(ameba/sync-status.json)は一切変更しない。
    • 教訓: 「収集日時」フィールドを過去データへ事後的にバックフィルする場合、その値は「パッチスクリプトを実行した日時」ではなく「対象データが実際に収集された(=当時のバッチが完了した)日時」でなければならない。前者を使うと、新着ウィンドウのような「直近の実行区間」を前提にした判定から漏れる。
    • 対象URLのハードコードは実際のワークフロー実行ログ全体で洗い出す: 初版は Issue 本文に記載された現役メンバー分のURLのみを対象にしており、同じ実行内で蓄積されたOG(卒業生)メンバー分の投稿(個人ブログ)が漏れていた。特定の1回の実行が対象の一次資料になる場合は、gh run view <run-id> --log で該当実行のログ全体を確認し、部分的な抜粋(Issue本文のログ転記等)だけで対象を確定しないこと。
    • 冪等性は「対象値に収束するか」で定義する: 当初は「既に capturedAt が設定済なら上書きしない」という冪等性だったため、誤った値(スクリプト実行時刻)で一度実行してしまうと、後から正しい値に修正しても再実行で補正できなかった。一度限りの補正用パッチスクリプトでは、「既に設定したい値と同じなら上書きしない・異なれば上書きする」という定義にしておくと、誤実行後の再実行でも正しい値に収束する。
    • scripts/patch/*.ts の revalidateCache 呼び出し漏れ(実装#11 の再発): このスクリプトの初版は Blob 書き戻し後に revalidateCache を呼んでいなかった。手動実行パッチスクリプトを新規作成する際は、既存の patch-member-nicknames.ts 等と同じく APP_URL/REVALIDATE_TOKEN を読み込み、put() 後に revalidateCache を呼ぶ雛形に揃えること。
    • 「対象URLが存在しない」を即座に対象外と判断せず、なぜ存在しないかを確認する: ハードコードした対象URLのうち1件(高橋愛の投稿)は dry-run で「変更0件」となったため、当初は「実際には蓄積されなかった投稿」として対象から除外した。しかし実際のブログページを直接確認すると、2026-07-24付の実在する投稿だった。原因はタイトル重複判定バグ(shouldSkipAccumulation、#1417で追跡)で、収集自体が一度も行われていなかった。過去データのバックフィル対象URLが statusHistory に見当たらない場合、「収集されなかった=対象外でよい」と決めつけず、実際の投稿ページを確認して「収集漏れ(別バグ)」の可能性を切り分けること。 後者だった場合、capturedAt の補正だけでは直せず、実際のタイトル・投稿日を確認した上でエントリ自体を新規追加する必要がある。
  11. 複数の独立したスケジュールが同じ対象データを更新する「新着」判定は、固定のカレンダー日境界ではなく実行区間ウィンドウで行う(#1450)

    • #95で YouTube の新着判定を「投稿日時」から「収集日時(capturedAt)基準」に直したが、そのときの実装は依然として「全チャンネル横断で最新の capturedAt を1件取得し、その JST 日付と同日のレコードを新着とする」という固定のカレンダー日境界方式だった。
    • YouTube は official(JST 21:30起動)・og(JST 22:00起動)という2つの独立したスケジュールが同じ youtube_posts テーブルを更新する構成のため、片方の実行が日付をまたいで遅延すると、先に収集されたもう片方の投稿だけが「前日扱い」となり新着判定から漏れた。単一スケジュールの収集(Instagram・TikTok)では顕在化しない、複数スケジュールが同じ読み取りパスを共有する場合に固有のバグである。
    • 対策(初版・後にPRレビューで不十分と判明): Ameba が既に採用していた実行区間ウィンドウ方式([previousAutoSyncedAt, lastSyncedAt]、#1387)を YouTube にも適用した。Neon に1行のみの youtube_sync_status テーブル(TikTokSyncStatus と同じ単一行 upsert パターン)を追加し、official・og 両方の収集スクリプトが実行完了時に同じ3カラムを共有スライドさせる、scripts/workflow/sync-status.ts の Ameba 向け実装と完全に同一のロジックにした。
    • この初版が再発させた別の不具合: Ameba は現役・OGを1つのワークフロー内でまとめて処理するため単一ウィンドウの共有スライドで問題ないが、YouTubeはofficial(毎日)・og(OG_SYNC_WEEKDAYS指定曜日)という独立した2つのスケジュールが同じ3カラムを共有スライドする。official実行の直後(数十分後)にog実行が完了すると、previousAutoSyncedAtがog自身の実行時刻まで押し上げられ、officialが数十秒前に収集した投稿のcapturedAtがウィンドウ下限を下回って新着から除外される。これは本Issueが修正しようとした「日付境界による見落とし」と全く同じ症状(official投稿が漏れる)が、原因を変えて再発したもの。#98参照。
    • 最終対策: youtube_sync_statusをofficial/og独立の6カラム(officialLastSyncedAt等・ogLastSyncedAt等)に分割し、updateYoutubeSyncStatus(channelType)で呼び出したチャンネルのカラムのみを更新するよう変更。getRecentYoutubePosts()もチャンネルごとに独立したウィンドウで判定する(OR条件)。詳細は「YouTube投稿一覧機能 データ設計書」§6.2参照。
    • 教訓: 「新着」判定を実装・レビューする際、判定対象データを更新するバッチ・ワークフローが複数の独立したスケジュールを持つかどうかを必ず確認すること。単一スケジュールなら固定のカレンダー日境界でも実用上問題になりにくいが、複数スケジュールが近接していると、どちらかの遅延で「日付をまたいだ側だけ排除される」不具合が高確率で発生する。新着ウィンドウは常に「その機能を支える収集バッチ自身の実行区間」を基準にすること。さらに、既存の類似実装(Ameba)を「同一ロジックだから」という理由で単純に流用する際は、その実装が前提とする「更新者は単一スケジュールか、複数の独立したスケジュールか」を必ず確認すること。単一スケジュール向けの共有カーソル方式を、複数の独立したスケジューラがいるケースにそのまま適用すると、共有カーソルの押し合いによって解決したはずのバグが別の形で再発する。
  12. bunx prisma <command> は bun.lock/package.json のバージョンではなく、node_modules に既に解決済のバージョンをそのまま使う(#1450)

    • package.json の prisma/@prisma/client を ^7.9.0 に更新済でも、bun install を実行せず bunx prisma generate を叩くと、node_modules に残っていた旧バージョン(このケースでは 7.8.0)でコマンドが実行され、bunx は警告なしにそのまま完走する。「さらに新しいバージョンが存在する」ケースでは末尾に Update available の案内が出るが、「lockfile が期待するバージョンと乖離している」ケースでは何も警告されない。
    • 教訓: Prisma スキーマを変更してマイグレーションを作成する前は、bun install を必ず実行してから bunx prisma generate → bunx prisma migrate dev --name <name> の順で進めること(実装#42の手順の前提条件として、bun install 省略不可を明示する)。git pull 直後の実装開始チェックリストと同じ理由で、依存関係が古いままだと不整合が症状化するまで気づけない。
  13. OfficialSummaryEntry 移行はSNS別収集スクリプト(#1455/#1456)が集約側(send-daily-digest.ts、#1457)より先にマージされる順序で分割されており、単体マージ時は集約側が一時的にクラッシュしうる(#1455)

    • send-daily-digest.ts の buildDigestEmailBody は entry.source === 'youtube-official' のみを特別扱いし、それ以外の source は無条件で MemberSummaryEntry にキャストして entries を反復する。instagram-official/tiktok-official 等の新しい OfficialSummaryEntry ソースが書き出されると、entries が存在せず(OfficialSummaryEntry は accounts を持つ)TypeError: memberEntry.entries is not iterable で send-daily-digest.ts の実行全体が失敗する。
    • 原因: Issue分割(#1423 親Issue → #1454 型拡張 → #1455 Instagram移行 / #1456 TikTok移行 → #1457 集約側改修、という依存関係)により、収集スクリプト側の移行と集約側の改修が別Issue・別PRになっている。#1457 は #1455・#1456 完了後に着手する設計のため、収集側だけ先にマージすると集約側の対応漏れ状態が一時的に生じる。
    • 対策: #1455・#1456 をマージした後は速やかに #1457 をマージすること。並行して複数の収集スクリプトを移行する場合、それぞれのPRマージ直後に daily-digest 送信ワークフロー(GitHub Actions)が実行される前に、集約側の対応が完了しているか確認すること。
    • 教訓: 収集スクリプト側と集約側のように「データを書き出す側」と「データを読む側」を別Issue・別PRに分割する場合、収集側の型・データ形状の変更が集約側に無条件でキャストされて読まれていないか(本件のように新しいソース種別だけ分岐から漏れていないか)を分割設計の時点で確認すること。分割そのものを避けるのではなく、先行Issueのマージ直後は後続Issueを最優先でレビュー・マージする運用にして、対応漏れ状態が本番ワークフローの実行タイミングと重ならないようにする。
  14. メンバー名の異体字(旧字体/新字体)対応漏れは、SNS別の attribution.ts 3ファイルに同じ関数が重複しているため個別に発生しうる(#1470) - TikTokで複数メンバーがハッシュタグ指定された投稿のうち1名しか紐付かない不具合を調査したところ、原因はキャプション側の表記「桜井梨央」(新字体)とBlob上のメンバーデータの表記「櫻井梨央」(旧字体「櫻」)が一致していないことだった。lib/tiktok-attribution.ts の normalizeForNameMatch() は 﨑→崎・髙→高・栁→柳・𠮷→吉 の4種の異体字正規化のみを持ち、櫻→桜 が未対応だった。 - 調査手法: Blobから取得した members.json の対象メンバーの name フィールドと、Neonに保存された投稿の実際の caption を突き合わせて文字単位で比較し、一致しない箇所を特定した(メンバー名は目視では新字体・旧字体の区別がつきにくいため、文字列比較で機械的に検出する必要がある)。 - 同一バグの横展開: normalizeForNameMatch() は lib/tiktok-attribution.ts・lib/youtube-attribution.ts・lib/instagram-attribution.ts の3ファイルに同一実装が重複しており、同じ原因のバグが3ファイルすべてに存在していた。共通モジュールへの切り出しはされていないため、1ファイルだけ直して他を放置すると同じ不具合が他のSNSでも再現する。 - 永続化の有無で対応が異なる: TikTok(lib/tiktok.ts の getTikTokPosts 経由、mentionedMemberIds をNeonに永続化)は収集済の過去データを遡及修正するワンショットパッチスクリプトが必要だが、YouTube/Instagramは app/members/[id]/page.tsx でリクエスト時に attributeYoutubeVideoToMembers/attributeInstagramPostToMembers を計算しており非永続化のため、コード修正のみで即座に反映されバックフィル不要だった。 - 教訓: 複数のSNS収集ロジックが同じパターン(ハッシュタグ抽出→メンバー名正規化→マッチング)で実装されているコードベースでは、1箇所で見つかった正規化漏れ・表記ゆれバグは、類似実装が他に重複していないか横展開調査を必ず行うこと(grep で関数名・処理パターンを検索)。また、修正対象データがBlob経由(非永続・キャッシュのみ)かNeon直接永続化かによって、バックフィルの要否が変わる点も合わせて確認すること。

  15. Amebaグループブログの投稿者判定は「タイトル末尾に最も近いメンバー名」で一意に絞り込む(#1480) - グループ共有ブログでは findMemberAmebaItem/findAllNewMemberAmebaItems(scripts/workflow/sync-status.ts)がメンバーごとに独立して「タイトルに自分の名前が含まれるか」を .includes() で判定していたため、タイトルに複数の共有メンバー名が含まれる投稿(実データ例: 「井上春華にお土産渡してみた弓桁朱琴」)では、言及されただけのメンバーにも投稿が重複帰属していた。 - 実際のAmeba RSS(https://rssblog.ameba.jp/{blogId}/rss20.xml)を curl で取得し全タイトルを確認したところ、例外なく投稿者本人の氏名がタイトル末尾に付記される運用だった。<category> タグ等の構造化された投稿者情報はRSSに存在しないため、この命名慣習をロジック化する以外に信頼できる判定手段がない。共有メンバー名の候補のうちタイトル内で最も末尾に近い位置(lastIndexOf が最大)に出現する名前を投稿者と判定する resolveAmebaGroupBlogAuthorName を新設した。 - 判定ロジックの一本化(実装#94の適用例): 同じ「タイトルからメンバーを判定する」ロジックが、日次バッチ(sync-status.ts のグループブログ振り分け)と手動パッチ(scripts/patch/patch-ameba-misattributed-status.ts の重複解消)の2箇所に独立実装されていた。修正では両方が resolveAmebaGroupBlogAuthorName を共有するよう統一し、判定ルールが片方だけ更新されて乖離する事態を防いだ。 - 既存関数への後方互換な拡張: isGroupBlog を持つ既存関数に groupMemberNames: string[] = [] をオプション引数として追加し、デフォルト(空配列)では従来どおりの単純 .includes() 判定に振る舞うよう設計した。呼び出し元(buildAmebaCache)が共有メンバー全員の氏名リストを渡した場合のみ、末尾優先の絞り込みが有効になる。この設計により、4箇所以上ある既存の呼び出し・テストを変更せずに新しい絞り込みロジックを追加できた。

  16. ランキングモードのキーフレームは基準メンバー1名の日付のみが基点のため、加入日より前は構造的に生成されない(#1310) - レーダーチャート時系列表示のキーフレーム算出で、think-issueの検討コメントには「ランキングモードで基準メンバー加入日より前のキーフレームが除外される」というテストケースが明記されていた。素直に実装すると keyframeDates.filter((d) => d.getTime() >= selectedJoin.getTime()) のようなガード処理を書きたくなる。 - しかし設計書(member-map-radar-chart-design.md §10-2)では、ランキングモード(buildMemberRankingTimeline)のキーフレーム基点は「基準メンバー1名」の加入日・卒業日のみに限定されている。基点が基準メンバー自身の日付しかない以上、そこから補完される全キーフレームは数学的に必ず加入日以降になり、上記フィルタは呼ばれても何も除去しないデッドコードになる。 - 対応: フィルタ処理は追加せず、「基点データ自体が対象を基準メンバー1名に限定しているため、加入日より前のキーフレームは生成され得ない」という設計上の性質(invariant)を実装コメントで明記し、テストではその不変条件(返り値が全て加入日以降であること)を確認する形にとどめた。

  17. 既存データがあるテーブルにNOT NULLカラムを複数追加する場合、prisma migrate devは実行を拒否する。--create-onlyでスケルトンを生成してからバックフィルを手で挿入する(#1489) - tiktok_sync_status(既存行1件)にofficial/og独立ウィンドウ用の6カラム(officialLastSyncedAt等、いずれもデフォルト値なしのNOT NULL DateTime)を追加しようとしたところ、bunx prisma migrate dev --name <name> が「Added the required column xxx … without a default value. There are 1 rows in this table, it is not possible to execute this step.」を6件出力してマイグレーションを一切生成せず停止した。実装#42「手書きSQL厳禁」の原則を守りつつ、この状況を解消する必要があった。 - 対応手順: bunx prisma migrate dev --name <name> --create-only を実行すると、Prisma自身が「実行はしないがファイルだけは作る」モードでスケルトンSQL(ALTER TABLE ... ADD COLUMN "xxx" TIMESTAMPTZ(3) NOT NULL を6個並べただけの、そのままでは実行不能なSQL)を生成する。このファイルを手で開き、各 ADD COLUMN から NOT NULL 制約を外して一旦nullableにし、直後に UPDATE 文でバックフィル(本件では新カラムを既存の lastSyncedAt の値で埋めた)、最後に ALTER COLUMN ... SET NOT NULL で制約を追加する3段構成に書き換えてから、bunx prisma migrate dev(--create-onlyなし)を再実行して適用する。 - 「手書きSQL厳禁」との関係: この手順は手書きSQLの全面禁止に反しない。Prismaが生成したカラム名・型定義(ALTER TABLEのテーブル名・カラム名・型)はそのまま流用し、追記するのは「値をどう埋めるか」というPrismaが知り得ないドメイン知識(バックフィル元カラム)の部分のみ。過去に類似の分割マイグレーション(youtube_sync_statusのofficial/og分割、#1450)でも同じ「ADD COLUMN(nullable)→UPDATE→SET NOT NULL」の3段構成が採用されており、本件はその前例に倣った。 - 教訓: 既存データがあるテーブルへの必須カラム追加でこのエラーに遭遇したら、手書きSQLを一から書くのではなく、必ず --create-only でPrisma生成のスケルトンを取得し、そこにバックフィルロジックだけを追記する。エラーメッセージ自体が対処法(--create-onlyの使用)を提示している。

  18. 日時データは取得できる精度のまま保持し、丸めが必要な利用側で丸める。パース処理で先回りして精度を落とすのはバグになりやすい(#1502) - scripts/workflow/sync-status.ts の parsePubDate は、Ameba RSS の <pubDate>(時刻付き)を Intl.DateTimeFormat で JST の "YYYY-MM-DD" にフォーマットし、時刻情報を意図的に切り捨てていた。この結果 StatusEntry.lastUpdated は常に日付のみとなり、「収集タイミングのずれで投稿日が翌日に見える」といった時刻起因の挙動を後から検証できなくなっていた。 - RSSの生データには元々時刻が含まれており、「時刻がない」のではなく「パース処理が意図的に捨てていた」だけだった。TikTok(createdAt)・YouTube(publishedAt)は同種の実投稿日時フィールドを最初から時刻付きで保持しており、Amebaだけこの精度が劣っていたのが実態。 - 対策: parsePubDate を date.toISOString()(時刻付き)を返すよう変更し、lastUpdated は自動収集由来なら時刻付きISO文字列、手動編集由来(実際の時刻が存在しない)なら従来通り日付のみ、という2パターンが混在する前提に変更した。日付単位の同一性判定・カットオフ比較が必要な箇所(components/member-detail/LatestStatusSection.tsx の同日グルーピング・30日フィルタ、app/api/members/route.ts の30日カットオフ)は、新設した lib/date.ts の toJstDateString() でJST暦日に丸めてから比較するよう修正した。丸めずに文字列の完全一致・レキシコグラフィック比較をしていた箇所は、時刻が付与された途端に「同じ日なのに一致しない」という形で壊れる。 - 教訓: 新しいフィールドを設計する際、パース・保存の時点で「今は使わないから」と精度を落とすのは避ける。精度は保持したまま持たせ、丸めが必要なユースケース側(表示・グルーピング・カットオフ判定)でその都度丸める設計にすること。後から精度を復元することはできないが、丸めるのはいつでもできる。

  19. unstable_cache はコード変更・dev serverの再起動だけでは古い結果を返し続ける。動作確認前に .next/cache を消してから再起動すること(#1502) - getRecentYoutubePosts 等(revalidate: 1800)のロジックを修正した後、Playwrightでトップページを確認したところ、明らかに条件を満たさないはずの投稿にも新しいバッジが表示され続けた。コードは正しいことを別スクリプトで確認済だったため、一見「表示側の条件分岐が壊れている」ように見えたが、実際は unstable_cache のディスクキャッシュ(.next/cache)がコード変更前の実行結果をそのまま返し続けていたことが原因だった。revalidate の秒数が経過するまで、あるいはキャッシュを明示的に消すまでは、関数の実装を書き換えても呼び出し結果は更新されない。 - dev serverを一度 pkill で止めたつもりでも、Windows環境では対象プロセスが実際には終了しておらず(別ポートで新プロセスが起動し「既存のサーバーがある」という警告が出た)、結果的に古いキャッシュを保持したサーバーが動き続けていたことも発覚を遅らせた要因だった。 - 対策: unstable_cache でラップした関数のロジックを変更した後にPlaywright等で実際の画面を確認する際は、rm -rf .next/cache でディスクキャッシュを明示的に削除してから dev server を再起動する。プロセスを止める際は pkill だけで満足せず、対象ポートが実際に解放されたか(Get-NetTCPConnection 等)を確認すること。 - 補足(Next.js 16、MorningStatusApp#1545): Next.js 16 の開発サーバー(next dev)は、unstable_cache のディスクキャッシュを .next/dev/cache(fetch-cache)に保存する。上記の .next/cache を消してもキャッシュは残り、コードが正しくても古い表示が続く。フェスの時刻を保存したのに過去のこの日へ反映されなかった件で、.next/cache を消して再確認しても変わらず、開発サーバーを止めて .next/dev/cache を消してから起動し直して、初めて反映を確認できた(.next/dev/cache の場所は、find .next -name '*cache*' で確認できる)。あわせて、本番(Vercel)でも、編集 API が revalidateTag でキャッシュを破棄していない unstable_cache は、編集の直後に、revalidate の秒数が経つまで古い表示が残る

  20. extractAmebaId(lib/sns.ts)はAmebaブログのトップページURLも「Amebaソース」として一致判定する(isAmebaRssEntryUrlとは判定範囲が異なる、#1513) - extractAmebaId は ameblo.jp ホストであれば、個別記事URL(/entry-NNNN.html)だけでなくブログトップページURL(https://ameblo.jp/{id}/)も同じように ameba_id を返す(null にならない)。「Amebaブログかどうか」の判定であり「個別投稿かどうか」の判定ではない。 - 一方、isAmebaRssEntryUrl は /entry-\d+\.html 形式のみを true とする、より厳密な「個別投稿URLかどうか」の判定関数(#1328で新設、RSS由来投稿とブログトップページ等の一覧ページを区別するため)。 - findSupportedBlogUrl(getStatusSourceLabel 等の呼び出し元)は extractAmebaId を使っているため、Exa検索がAmebaブログのトップページURLを出典として返した場合でも「Ameba投稿」として分類される。個別記事に絞りたい場合は isAmebaRssEntryUrl を使うこと。 - 教訓: 名前が似た2つの判定関数(extractAmebaId と isAmebaRssEntryUrl)が別の粒度で「Amebaかどうか」を判定しているため、新しい実装で「Amebaソースかどうか」を判定する際は、目的が「ドメイン一致(ブログ全体)」なのか「個別投稿の形式一致」なのかを先に決めてから使う関数を選ぶこと。 - 教訓: unstable_cache を使った関数の動作確認で「コードは合っているはずなのに画面の挙動がおかしい」という状況に遭遇したら、まずコードのロジックを疑う前に、ディスクキャッシュの古さとdev serverプロセスの残存を疑うこと。

  21. 既存の「常時表示」原則がある一覧をグループ単位に再編する際は、対象がグループをまたいで重複しうる点を踏まえてグループ単位で原則を維持し直す(#1514) - NewPostsSection は Instagram/TikTok/YouTube/Amebaの4セクション固定構成から「公式/現役/OG」の3ブロック構成へ再編したが、Instagramは3ブロック全て、TikTok・YouTubeは公式/OGの2ブロック、Amebaは現役/OGの2ブロックというように、同じSNSが複数ブロックにまたがって存在しうる設計になった。 - 再編前は開発ノート#11(#1404)により「各SNSセクション(見出し・一覧画面へのリンク)は新着0件でも常時表示する」という原則が守られていたが、グループ単位の再編を行う際、この原則を「ページ全体でその媒体へのリンクが最低1箇所あればよい」と読み替えてしまうと、該当SNSが属する全ブロックで同時に0件になった日にリンクが消える再発リスクがある(例: TikTokが属する公式・OGブロックの両方が同日0件なら、旧実装と同じ「導線喪失」バグが起きる)。 - 対策: 「常時表示」の原則は、グループ化後もそのSNSが属する各グループそれぞれの中で維持する(0件のグループでも見出し・リンク・プレースホルダーを描画する)。1箇所でも表示されていれば良いという緩い解釈をしない。 - 実装時にこの点をユーザーに確認したところ「割り切らない(=原則を緩めない)」という回答だった。一覧画面への導線に関わる既存原則は、UIの再編作業では見落としやすいため、再編に着手する前に「この原則は再編後も対象ごとに独立して成立するか」を明示的に問い直すこと。

  22. 親レコードを使い回しながら子レコードだけ追加登録する「後追い型の差分登録」は、親の重複判定と子の重複判定を別々のキーで独立させて設計する(register-tour-festival.ts、#1520) - scripts/console/register-tour-festival.ts(フェス・ツアー登録バッチ)のセットリスト後追加要件(公演確定後にセットリストだけ後から追加登録したい)を実装する際、最初はツアー・フェスどちらも「日程・会場が重複する入力は丸ごとスキップする」設計にしていたが、これだと親(tour/festival)が既存でも、その親に新しく紐づけたい子(setlist の曲)まで一緒にスキップされてしまい、後追い登録ができなかった。 - ツアーは元々 Setlist.tourId 単位で親(tour)1件に対し子(setlist の曲)が複数ぶら下がる構造で、子の重複判定(existingSetlistSeqs に含まれる seq かどうか)を親の重複判定(live の日程・会場)と最初から独立させていたため、この問題が起きなかった。フェスは occurrences の1要素が1 Festival レコードに直結するため、「occurrence が重複=親も子も丸ごとスキップ」という設計に引きずられていたのが原因。 - 対策: 親を再利用するケース(occurrence の日程・会場が既存 Festival と一致)では、新規 Festival の作成はスキップしつつ、その既存 festivalId に対して子(setlist)の重複チェック・追加登録は独立して行う。子の重複判定は「入力に含まれる seq が、その親IDに紐づく既存レコードの seq 集合に含まれるか」で行い、親を新規作成したか再利用したかに関わらず同じロジックを通す(buildFestivalPlan 内で existingFestivalIdByKey から再利用IDを引いた後、必ず existingSetlistSeqsByFestivalId で子の重複チェックを行う)。 - この「親は重複してもよい(再利用する)が子は重複させない」という要件は、親子関係を持つ差分登録バッチ全般(既存の親エンティティに後から明細行を追加する系のバッチ)に共通するパターンのため、同種のバッチを新設する際は親・子それぞれの重複判定キーを最初から別に設計すること。

  23. Google Maps Geocoding APIのregionパラメータは検索結果の地理的な絞り込み用であり、レスポンスの表記言語には影響しない。言語を指定するには別途languageパラメータが必要(register-tour-festival.ts、#1520) - region=jpだけを指定してジオコーディングを実行したところ、address_componentsの都道府県名(administrative_area_level_1のlong_name)が"Tokyo"のように英語表記で返ってくることがあった(実機テストで発覚。ローカル環境やクエリ内容によって英語/日本語のどちらが返るかが変わりうる)。 - prefecture_codes.name(日本語表記)との完全一致判定に依存していたため、英語表記で返るとaliases側でカバーしていない限り都道府県コードへの変換に失敗し、該当会場のジオコーディングが「失敗」として扱われてしまっていた。 - 対策: リクエストにlanguage=jaを明示的に追加する。regionは地理的バイアス、languageはレスポンスの表記言語という別軸のパラメータであることを区別すること。

  24. ??(Nullish coalescing)は空文字列をフォールバックしない。「未入力」が空文字列で表現されうるフィールドの自動導出フォールバックには||を使う(register-tour-festival.ts、#1527) - フェスID自動採番で const slug = input.slug ?? deriveSlug(input.name) という実装になっていたが、??はnull/undefinedのみフォールバックし、空文字列はそのまま採用してしまう。 - 入力スキーマのslugはz.string().nullable().optional()で空文字列も型上許容されており.min(1)等のバリデーションもなかったため、入力JSONで"slug": ""と書かれるとバリデーションを素通りし、festivalIdが2026--1のような不正形式になっていた。本番相当DBで実際にこの形式のレコードが登録済であることを実装時に確認した。 - 対策: 「未入力」を表す値がフィールドの型上「null/undefined」だけでなく「空文字列」も含みうる場合、フォールバック式には??ではなく||を使う。逆に空文字列と未指定を明確に区別したい文脈(実装#15の部分更新APIパターン)では??を使う。同じ「フォールバック」でもフィールドの意味論によって演算子の選択が変わる。

  25. PKの一部に親IDを文字列として埋め込むカラムは、親テーブルのPK更新時にON UPDATE CASCADEの対象にならない(Setlist.setlistId、#1538) - #1527のバグで生成された不正festivalId(2026--1)を補正するパッチ(#1538)を実装する際、Setlist.festivalId(FK列)にはON UPDATE CASCADEが設定されているため、Festival.festivalId(PK)をprisma.festival.update()で直接更新すれば、FK列側はDBレベルで自動的に追随することを確認した。 - しかしSetlistのPK自体(setlistId)はbuildSetlistId(festivalId, seq) = ${festivalId}-${seq}で生成される独立した文字列カラムであり、FK連鎖の対象外。親のfestivalIdを書き換えても、setlistIdは古い値を埋め込んだ文字列のまま残り、{festivalId}-{seq}という命名規約(batch-design.mdx)と食い違う状態になる。 - 対策: 親IDを文字列としてPKに埋め込む形式のテーブル(本プロジェクトではSetlist.setlistIdが該当)で、親のID自体を書き換えるパッチを書く際は、対象に紐づく子レコードの有無を確認すること。存在する場合はFK列の自動追随だけで完了したと誤認せず、子レコードのPK(埋め込み文字列)も作り直す必要がないか検討する。今回は対象2件ともSetlistが0件だったため実装は不要だったが、将来同様のケースが発生した場合に気づけるよう、パッチスクリプト側に警告ログを仕込んだ。

  26. パターンマッチで不正データを検出する一回限りパッチでは、同じパターンが仕様上正当なケースと重ならないか確認する(deriveSlugの空文字列仕様、#1538) - {年}--{連番}形式の不正festivalIdを補正するパッチ(#1538)を実装する際、deriveSlug(name)が完全に日本語のみのnameで空文字列を返すケースは、batch-design.mdxの仕様上「空スラッグを許容する」正常な状態であると気づいた。この場合もfestivalIdは{年}--{連番}という同じ文字列パターンになるため、パターン一致だけでは「バグによる不正データ」と「仕様通りの正常データ」を区別できない。 - 対策: パターンマッチで補正対象を洗い出す一回限りパッチでは、パターン一致を機械的に補正条件にせず、そのパターンが仕様上正当なケースと重ならないか設計書を確認する。重なる場合は、パターン一致に加えて追加の判定条件(今回はderiveSlugの再実行結果が非空であること)を設け、正当なデータを誤って書き換えないようにする。

  27. 文字列の「正規化してマッチングする」関数を複数箇所で共通化する際は、名前ではなくアルゴリズムの型(完全一致キー用か、自由文からの部分一致検索用か)で使い分けを判断する(lib/track-match.ts、MorningStatusApp #1552) - ラジオ・YouTube・セットリストで使っていた完全一致キー用の正規化関数(スペース・記号を全て除去する強い正規化)を、TikTokのキャプション内曲名検索(titleMatchesCaption)にもそのまま流用したところ、既存のテストが軒並み失敗した。 - 原因は、titleMatchesCaptionが「マッチした部分の前後がCJK文字でないか」という単語境界の安全確認にキャプション中の実際のスペースを使っていたこと。強い正規化でスペースごと除去すると、本来スペースで区切られ安全だった境界が消え、直後に別の日本語が続く扱いになって誤って「境界外」と判定されてしまう。 - 一方、同じ強い正規化を.includes()による単純部分一致(collect-tiktok-posts.ts)や、曲名同士の重複排除キー(buildTrackMatchMapFromPrisma、app/tiktok/official/[accountId]/page.tsxのtrackInfos構築処理)に使う分には問題が起きない。これらは前後の文字への依存がない、または比較対象が常に「曲名まるごと1つの文字列」だから。 - さらに、titleMatchesCaption内のハッシュタグ完全一致判定だけは、ハッシュタグが構造上スペースを含み得ないため、強い正規化(スペース除去あり)で比較する必要がある——同じ関数の中でも判定の型によって必要な正規化強度が異なっていた。 - 対策: 正規化関数を複数箇所で共通化する際は、関数名や「同じ処理をしている」という見た目の共通性だけで判断せず、各呼び出し箇所が「完全一致キー生成」なのか「自由文内での境界安全な部分一致検索」なのかというアルゴリズムの型を先に特定すること。前者はnormalizeForMatch(強い正規化)、後者はnormalizeSymbolVariantsForMatch(NFKC・小文字化・記号の文字種統一のみ、スペース等は残す)のように、強度の異なる正規化を明示的に使い分ける設計にする。

  28. Prisma.Decimal型のフィールドを含むモデルをServer ComponentからClient Componentへpropsで渡す設計では、selectで該当フィールドを最初から除外する(Venue.latitude/longitude、MorningStatusApp #1522) - デスクトップモード編集画面(年別ライブ一覧・フェス詳細)で会場選択用の会場一覧をClient Componentへ渡す実装をした際、既存コード(app/lives/prefecture/[code]/page.tsx)はprisma.venue.findMany()でVenueをフル取得した上でNumber(v.latitude)のように呼び出し側で変換していた。今回は緯度経度そのものを使わない画面だったため、select: { venueId: true, name: true, prefecture: true }で最初からDecimal型フィールドを問い合わせに含めない設計にした。 - Prisma.DecimalはReact Server ComponentsのFlightシリアライズが素通しできるDate/Map/Set等とは異なり、素のクラスインスタンスのため、Client Componentへの受け渡し前に何らかの変換(Number()化等)が必須になる。変換を忘れるとpropsが正しくシリアライズされない、または実行時エラーになる。 - 対策: Client Componentへ渡す目的でPrismaモデルを取得する場合、緯度経度等のDecimalフィールドが実際にその画面で必要かをまず確認する。不要ならselectで最初から除外するのが、取得後にNumber()変換するより簡潔かつクエリコストも小さい。必要な場合のみ、既存パターン通りNumber(v.latitude)等で明示的に変換してから渡す。

  29. next.config.tsのheaders()はNext.jsのISRが自動付与するCache-Control(stale-while-revalidate等)を上書きできる。ElectrobunのBrowserViewにはWebViewキャッシュを明示的にクリアするAPIが無い(#1561) - デスクトップアプリ(Electrobun)でNext.jsを再ビルド・.appを再インストールしても、WKWebView(macOS)がstale-while-revalidate付きの古いレスポンスをディスクキャッシュしたまま表示し続けるバグがあった。.appの再インストールではWebView側のキャッシュ(~/Library/WebKit/{bundle-id}/WebsiteDataStore/*/NetworkCache、macOSの場合)は消えないため、サーバー側の実装が新しくなっても画面には反映されなかった。 - desktop/node_modules/electrobun/dist/api/bun/core/BrowserView.ts・BrowserWindow.tsを確認したが、partitionオプションはあるものの用途が未文書化で、WebViewの既存キャッシュを明示的にクリアするAPIはElectrobun 1.18.1時点では存在しない。 - 対策: WebView側のキャッシュを操作するのではなく、サーバー側のレスポンスヘッダーで無効化する。next.config.tsのheaders()に{ source: '/:path*', headers: [{ key: 'Cache-Control', value: 'no-store' }] }を追加すると、ISRが内部的に設定するs-maxage/stale-while-revalidate付きのCache-Controlを実際に上書きできることを実機(standaloneサーバーへのcurl -I)で確認した。デスクトップモードはローカル単一ユーザー向けでCDNキャッシュの恩恵がないため、DESKTOP_MODE=1時のみ適用してWeb版(Vercel)のISRキャッシュ設計には影響させない。 - 併せて、Next.jsサーバーの子プロセスがprocess.on("exit")のクリーンアップを素通りして残留し、次回起動時にポートを掴んでいる旧プロセスへ接続してしまう不具合もあった。OS一時ディレクトリにPIDを記録するロックファイル方式(起動前に前回のPIDをkillしてから新規spawn)で解消した。Electrobunのapp.on("before-quit", handler)(electrobun/bunのappexport、イベント名はApplicationEvents.tsのbeforeQuit)は正常終了時の追加のクリーンアップフックとして使えるが、異常終了(強制終了等)では発火しないため、ロックファイル方式のような次回起動時の自己修復が本質的な対策になる。

  30. Next.jsのdistDirをワークスペースの一部(package.jsonが存在するディレクトリ)の配下に置くと、standalone出力が自分自身を再帰的に含む(Electrobun 2.0.1移行、#1548) - desktop/electrobun.config.tsのbuild.copyはプロジェクト(desktop/)配下の相対パスしか参照できず、..を含むパスを拒否するため、リポジトリルートに生成される.next/standaloneを直接参照できない。これを回避するため、Next.jsのdistDirをdesktop/.next-desktopのようにdesktop/配下へ変更する案を試した。 - desktop/には独自のpackage.json(electrobun依存を持つ)が存在するため、Next.jsのファイルトレーサーがdesktop/をワークスペースパッケージとみなし、standalone出力にdesktop/自身(ビルド中に生成されたdistDirを含む)を再帰的にコピーしてしまう自己参照が発生した。outputFileTracingExcludes: { '*': ['desktop/**'] }で除外しても、per-routeのファイルトレースとは別経路(ワークスペースパッケージ検出によるコピー)で数MB規模の残骸が残り、完全には解消しなかった。 - 対策: distDirはリポジトリルートの既定(.next)のままにし、build.scripts.preBuildフック(desktop/scripts/stage-nextjs.ts)で.next/standalone等を一度desktop/配下にステージングしてからbuild.copyで拾う二段構えにした。 - 教訓: モノレポ的な構成(サブディレクトリに独自のpackage.jsonがある)で、Next.jsのdistDirをそのサブディレクトリ配下に変更する最適化は、ファイルトレーサーの自己参照リスクがあるため慎重に検証すること。

  31. Node.js/Bunのfs.cpSyncはWindowsでシンボリックリンクを複製しようとするとEIOで失敗する。dereference: trueでリンク先の実体を複製する(desktop/scripts/stage-nextjs.ts、#1548) - Next.jsのstandalone出力(.next/standalone/)には、モジュール解決の都合で@prisma/client等へのシンボリックリンクが含まれる。これをWindows上でfs.cpSync(src, dest, { recursive: true })で複製しようとすると、EIO: Unexpected, cp '...\node_modules\@prisma\client-xxxxx'で失敗する(errno: -5)。 - 対策: cpSyncのオプションにdereference: trueを追加し、シンボリックリンクをそのまま複製するのではなくリンク先の実体(実ファイル)を複製するよう変更した。

  32. Git Bash環境のtarはGNU tar(/usr/bin/tar)に解決され、Windowsパス(D:\...)をリモートホスト指定と誤解釈する。ビルドツールが内部でtarを呼ぶ場合はPowerShell等、ネイティブのtar.exe(bsdtar、C:\Windows\System32\tar.exe)がPATH上位に来る環境で実行する(#1548) - Electrobun 2.0.1(Hutch)のelectrobun build --env=stableをGit Bash(本プロジェクトの開発で使うBashツール)から実行すると、リリースパッケージ生成時にtar: Cannot connect to D: resolve failed → hutch electrobun: command failed: tar → error: ReleaseCommandFailedで失敗した。 - 原因はGit BashのPATHで/usr/bin(GNU tar)がC:\Windows\System32(Windows純正のbsdtar)より優先されること。GNU tarはD:\...のようなドライブレター付きパスの:を[user@]host:path形式のリモートtape指定と誤認識する(tar --versionでGNU tarと表示される環境かを確認できる。Windows純正はbsdtarと表示される)。 - 対策: Hutchのtar呼び出しを含むコマンド(electrobun build)は、PowerShell等、GNU tarがPATH上位に来ない環境から実行する。本プロジェクトのsetup/build-installer.ps1・setup/build-macos.shは元々PowerShell/bashのネイティブ環境を前提にしているため実害はないが、Claude Code等のエージェントがGit Bash経由でこれらのビルドコマンドを直接実行する場合は要注意。

  33. electrobun.config.tsの型定義(ElectrobunConfig)はプロジェクトのdevkit(desktop/.hutch/devkit/api/config/ElectrobunConfig.ts)にあり、Web検索・AI要約で得た設定例を鵜呑みにせず、実際にインストールされた型定義ファイルで検証すること(#1548) - Electrobun 2.0.1移行時、build.bunVersion(v1)の代替としてbuild.bun.versionを設定した。Web検索経由の非公式な要約情報では「bun.versionでバージョン固定できる」という説明が得られたが、実際の型定義を確認するとbuild.bunの型は{ entrypoint?: string } & BundlerOptionsでversionフィールドは存在しなかった。zig/rust/go/odinにはversion?: string(トップチェーン上書き用)が用意されている一方、bun・cottontailには無い。 - satisfies ElectrobunConfigを付けていなかった場合、この無効なフィールドはTypeScriptにもHutchのランタイムにも拒否されずに黙って無視される(electrobun config --env=stableのバリデーションも通過する)ため、実際に効果がないまま「意図通り動いている」と誤認しやすい。 - 対策: electrobun.config.tsはsatisfies ElectrobunConfigを付けて型チェックの対象にする(無効なプロパティがあればコンパイルエラーで気づける)。Web検索・AI要約は設定例の出発点として使ってよいが、実際に使う前にdesktop/.hutch/devkit/api/config/ElectrobunConfig.ts(electrobun sync実行後に生成される)で該当フィールドが本当に存在するか確認すること。

  34. Vitest・ESLintのデフォルトの探索範囲はdesktop/.hutch/(Hutchが生成するベンダー同梱ファイル)まで拾ってしまう。明示的な除外設定が必要(#1548) - desktop/.hutch/devkit/配下にはHutch(Electrobun 2.0.1のビルドツールチェーン)自身の__tests__(bun:test前提)やソースファイルが同梱されている。Vitestのexclude設定にdesktop/.hutch/**を追加しないと、これらのテストファイルがVitestにバンドルされてCannot bundle Node.js built-in "bun:test"で失敗する。ESLintも同様にdesktop/.hutch/**・desktop/.next-standalone-staging/**(本プロジェクトのステージング先)をglobalIgnoresに追加しないと、ベンダー同梱の圧縮JSやビルド生成物に対して大量の警告・エラーを出す。 - 対策: vitest.config.tsのtest.exclude、eslint.config.mjsのglobalIgnoresの両方に、Hutchが生成するディレクトリ(desktop/.hutch)とプロジェクト固有のビルド生成物(desktop/.next-standalone-staging等)を追加すること。electrobun sync/ビルドを初めて実行した後は、これらの品質チェックコマンドが新たに生成物を拾っていないか確認する習慣をつける。

  35. Blobデータの移行(Blob→Neon)後、旧ストレージ名(例: releases.json)への言及は移行対象の機能に直接関係しない設計書にも残り続けることがある(#854移行後、MorningStatusApp #1529/morning-status-blume#80対応中に発見) - releases.jsonはMorningStatusApp #854でNeonのRelease/Trackテーブルへ移行済だったが、live-data-design.mdx(ライブ・フェス機能)のsetlists列説明・ER図・「Blobデータとの FK制約」節の計3箇所に「Blob管理のreleases.json参照」という記載が是正されないまま2ヶ月以上残っていた。移行作業のIssue自体は該当のDBスキーマ設計書(release-data-design.md等)は更新していたが、参照する側(setlistsのようにtrackId/releaseIdを“borrow”している別ドメインの設計書)までは追随していなかった。 - 対策: Blob→Neon等のデータストア移行を行うIssueでは、移行対象のテーブル名・ファイル名(例: releases.json)を対象ドキュメントに限定せずdocs/design/全体でgrepし、参照している全ての設計書(ER図のコメント・備考欄を含む)を洗い出してから着手する。移行のIssueで見つけられなかった場合でも、後続の無関係な作業でその設計書を触った際に気づいたら、その場で是正する(旧仕様の記載方針に従い「旧仕様の記載」として経緯を残す)。

  36. Claude Code等の非対話環境ではprisma migrate devが実行不可。migrate diff+手動マイグレーションファイル作成+migrate deployで代替する(MorningStatusApp #1558) - bunx prisma migrate dev --name <name> --create-onlyは、破壊的変更(列削除等)を検知すると対話確認を挟むため、非対話環境ではError: Prisma Migrate has detected that the environment is non-interactiveで即失敗する(--create-onlyを付けても回避不可)。 - 代替手順: bunx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --scriptで対象の生SQLを取得し(--from-migrationsはshadow DB必須で設定が要るため--from-config-datasourceで実DBと直接比較する)、prisma/migrations/<timestamp>_<name>/migration.sqlを手動で作成してそのSQLを配置し、bunx prisma migrate deploy(非対話でも動く)で適用する。 - 既存データがある列にFK制約付き中間テーブルを新設する場合の注意: 旧列(FK制約なし)に、参照先が既に存在しない値(本件では日付データの不整合に由来)が残っていることがある。バックフィルのINSERTを無条件で書くとFK違反でトランザクション全体が失敗する(DDL部分もロールバックされず中途半端な状態でDBに残ることがあるため、失敗時はprisma migrate resolve --rolled-backで記録を戻し、生成済テーブルを手動DROP TABLEしてから修正版を再適用する)。事前にLEFT JOINで参照先が存在しない行を洗い出し、バックフィルINSERTにWHERE EXISTS (...)を追加して除外することで、既存の不整合データを暗黙に「修正」せず、実行時の解決不能表示(例: 「不明な曲」)を移行後も維持できる。 - DB接続なしで生成する方法と、本番DBしかない場合の進め方(MorningStatusApp #1632): 直前のコミットのスキーマをgit show HEAD:prisma/schema.prisma > /tmp/schema-before.prismaで取り出し、bunx prisma migrate diff --from-schema /tmp/schema-before.prisma --to-schema prisma/schema.prisma --scriptでSQLを生成すると、接続先DBの現状に依存せず、追加したモデル分のSQLだけが得られる(--from-config-datasourceは接続先DBとの差分になる)。開発用DBがなく接続先が本番のみの場合、migrate devはドリフトを検知するとDBのリセットを提案する動作をするため使わない。bunx prisma migrate statusで「未適用は今回の1件のみ・ドリフトなし」を読み取り確認してからmigrate deploy(未適用分のみを適用)する。

  37. TypeScriptの超過プロパティチェックは、オブジェクトリテラルを直接渡す場合のみ働く。変数経由の代入では効かないため、Prismaスキーマから削除したフィールドが.map()の戻り値に残っていてもtsc --noEmitは検出しない(MorningStatusApp #1558) - const data = rows.map((r) => ({ ..., removedField: r.x }))のように一度変数dataに束縛してからprisma.model.createMany({ data })に渡すと、removedFieldがPrismaの生成型に存在しなくても構造的には代入可能と判定され、コンパイルエラーにならない。オブジェクトリテラルを直接引数に書いた場合(createMany({ data: [{ ..., removedField: ... }] }))は超過プロパティチェックが働きエラーになるため、書き方次第で検出可否が変わる。 - 実行時には Prisma Client が未知の引数としてPrismaClientValidationErrorを投げるため、型チェックが通っても安心せず、スキーマ変更時は削除したフィールドへの参照をgrepで機械的に洗い出すこと(型チェック任せにしない)。

  38. 判別ユニオンのcontinue/returnによる絞り込みは、単純な===のOR比較では効かないことがある。型ガード関数(entry is T)またはin演算子による判定に置き換えると安定して絞り込める(send-daily-digest.ts、MorningStatusApp #1573) - for (const entry of entries) { if (entry.source === 'a' || entry.source === 'b' || entry.source === 'c') { ...; continue; } entry.someMemberOnlyField; }という書き方では、ifブロック内(真の分岐)は判別ユニオンの絞り込みが効くが、continue後の残りのコードでは絞り込みが効かずTS2339: Property '...' does not exist on type '...'になる場合がある。 - 同じ判別ユニオン(DailyDigestEntry = MemberSummaryEntry | OfficialSummaryEntry)に対し、lib/daily-digest.ts側では既にentry is OfficialSummaryEntryという型ガード関数('accounts' in entryで判定)を定義済だった。同じ判定ロジックでも、ユーザー定義の型ガード関数やin演算子による判定はTypeScriptが確実に絞り込みを行うため、複数の===比較を||で連結する書き方より安定する。 - 対策: 判別ユニオンを複数箇所で判定する場合は、判定ロジックを型ガード関数として1箇所に集約し(exportして共有)、呼び出し側で使い回す。これにより絞り込みの安定性が上がるだけでなく、同じ判定ロジックの重複も防げる。

  39. 文字列型のreleaseDateを<比較で最古判定する際、空文字列は常に最小値として扱われるため、未設定データが誤って「最古」に選ばれる(lib/track-match.ts、MorningStatusApp #1575) - buildTrackCandidatesMapFromPrismaに「非シングル候補の中から最古のリリース日を1件採用する」制約を追加する実装調査時、本番DBにreleaseDateが空文字のアルバムが2件存在することが判明した。''は文字列比較で常にどんな日付文字列('2000-01-01'等)よりも小さいと判定されるため、単純にa.releaseDate < b.releaseDateで最古を選ぶと、実際のリリース日が不明なレコードが常に「最古」として誤選択されてしまう。 - 対策: 日付らしき文字列を</>で比較する前に、空文字列(または未設定を示すプレースホルダー値)を候補から除外する。除外した結果、有効な値を持つ候補が1件もない場合のみ、任意の基準(配列順の先頭等)にフォールバックする。

  40. JSONから毎回全件再同期する子テーブルの同期方式を設計する際、「配列っぽいから配列カラムの挙動を真似る」ではなく「同じ構造(PKを持つテーブル)の親がどう同期しているか」を確認する(eventsテーブル会場・楽曲属性設計、MorningStatusApp #1528・morning-status-blume#67) - 手動イベント(data/inputs/manual-events.json)に楽曲属性(event_songs/event_song_tracks)を追加する設計をした際、最初は「songsが指定されたら該当イベントの曲を全件delete→re-insertする」洗い替え方式を提案した。根拠はtags(TEXT[]列)が明示指定時に配列全体を上書きする挙動との類推だったが、これは誤った前例参照だった。 - tagsはPostgresの配列カラムであり「1要素だけ更新する」という概念自体が存在しないため、洗い替えが唯一の実装。一方event_songsは独自PK(event_song_id)を持つテーブルであり、比較すべき前例はtagsではなく、同じ「PKを持つテーブルをJSONから再同期する」構造を持つevents本体自身だった。events本体はsource+sourceIdをキーにした差分upsert(upsertEvent()。既存レコードはidを保持したままUPDATE、新規のみINSERT)で再同期しており、洗い替えは行っていない。 - 対策: event_songsをseqをキーにした差分方式(既存seqはevent_song_idを保持したままUPDATE、新規seqはINSERT、入力にない既存seqはDELETE)に変更した。なお「曲名からtrackIdが解決できる子テーブル(event_song_tracks)」は複合PKのみで独自の代理キーを持たないため、こちらは全曲について毎回再解決・洗い替えしてもID再採番の問題が起きない。この「親エンティティの識別子は安定させつつ、紐付け先(多対多の中間テーブル)は毎回再解決する」という分離により、未解決だった曲名が後日Track登録された際に自動で紐付く「自己修復」の利点も両立できた。 - 教訓: 新設する子テーブルの同期方式を決める際は、表面的な性質(配列っぽい・繰り返し構造)で近い既存パターンを探すのではなく、「PKを持つ独立したレコードか」「その識別子を他から参照する予定があるか」というデータ構造上の性質で前例を選ぶこと。とりわけ、直接の親テーブル自身が同じ状況(JSONからの繰り返し再同期)をどう扱っているかは、最初に確認すべき最有力の前例になる。

  41. scripts/console/(手動実行用ワンショットスクリプト)内のロジックをバッチ(scripts/workflow/)から再利用したくなったら、console側から直接importせずscripts/lib/へ切り出す(会場ジオコーディングの共有、MorningStatusApp #1533) - 手動イベント同期バッチ(sync-events.ts)に会場(venueName→venueId)解決を追加する際、同じロジックがregister-tour-festival.ts(scripts/console/)に既に実装済だった。sync-events.tsからregister-tour-festival.tsを直接importすれば動作はするが、「日次バッチ(workflow/)が手動実行専用スクリプト(console/)に依存する」逆転した依存関係になり、console/側のCLI固有の変更(引数パース等)がバッチに波及するリスクを抱える。 - 対策: 会場解決ロジック(findUnresolvedVenueRequests・geocodeUnresolvedVenues・assignVenueIdsとその型)をscripts/lib/venue-resolver.tsに抽出し、register-tour-festival.ts・sync-events.tsの両方がそこから import する形にした。register-tour-festival.ts側はexport { ... } from '../lib/venue-resolver'で再エクスポートし、既存の呼び出し元・テスト(register-tour-festival.test.ts)の import パスは変更不要にした(buildSetlistIdをlib/setlist-helpers.tsから取り込んで再エクスポートしている既存パターンと同型)。 - 判断基準: scripts/console/のスクリプトから再利用したいロジックを見つけたら、まず「そのロジックはCLI引数・ファイルI/O等のconsole固有の関心事と分離できるか」を確認し、分離できる純粋なドメインロジックならscripts/lib/へ切り出す。切り出さずに直接importで済ませるのは、依存元が同じconsole/同士(あるいはworkflow/同士)のときに限る。

  42. 「本番Vercelデプロイのプロセス外から呼ばれるNext.jsキャッシュ無効化(revalidatePath/revalidateTag)は、呼び出し元自身のプロセスしか無効化しない」という制約は、手動パッチスクリプト(知見#11)だけでなく、デスクトップアプリ自身のAPIルートにも同様に当てはまる(MorningStatusApp #1600) - app/api/link-editor(PATCH)・app/api/tracks/[id]/youtube-link(PUT/DELETE)はrequireDesktopMode()ガード付きのデスクトップ専用エンドポイントで、app/api/members(PUT)にはこのガードが無かった(後に発覚。知見#129参照)。この3エンドポイントはいずれも書き込み成功後にrevalidatePath/revalidateTagを呼んでいた。これはデスクトップアプリ自身の standalone Next.js サーバープロセス内のキャッシュしか無効化せず、別プロセス・別デプロイである本番Web(Vercel)側のData Cache(fetch(..., { next: { revalidate } })やunstable_cache(..., { tags }))には一切影響しない。デスクトップで編集・保存しても本番Webには最大revalidate秒(例: getMembersFromBlob()は3600秒)古いデータが表示され続けるバグになっていた。 - 横展開の見つけ方: 1箇所(app/api/members)の不具合を修正する際、同じrequireDesktopMode()パターンを持つAPIルートを全て洗い出し、各々の書き込み先データが「Next.jsのData Cache(fetchのnext.revalidateまたはunstable_cache)を経由して読まれているか」を確認した。Prisma直読み込み+force-dynamic(キャッシュ層を経由しない)のエンドポイント(festivals・lives・setlists)は対象外、unstable_cache(getReleases, { tags: ['releases'] })を経由するlink-editor・tracks/[id]/youtube-linkは対象、という判定基準になった(知見#87の「Blob経由データにのみ必要」をunstable_cacheの場合にも一般化)。 - 対策: scripts/patch/revalidate-cache.tsのrevalidateCache()をlib/revalidate-cache.tsへ移設(アプリ本体からも呼ばれるようになったため)し、DESKTOP_MODE==='1'かつAPP_URL/REVALIDATE_TOKEN設定済の場合のみ本番へ/api/revalidateを呼ぶrevalidateProductionCacheFromDesktop()ラッパーを追加。ローカルのrevalidatePath/revalidateTag呼び出しに続けてこれを呼ぶことで、主処理(データ保存自体)の成否とは独立させた(キャッシュ再検証が失敗しても保存自体は成功として返す。エラーはログのみ)。

  43. 既存機能に「デスクトップ限定」という新しいアクセス制御概念を後付けする際、クライアント側の表示制御だけを更新し、サーバー側APIの認可実装を見落とすリスクがある(MorningStatusApp #1603) - app/api/members(PUT、メンバー編集機能)は2026-02-22、DESKTOP_MODE/デスクトップアプリという概念自体がまだ存在しない時期に実装された最初期の編集APIだった。2026-04-19にElectrobunデスクトップアプリのPoCを実装した際、同じコミットでMemberDetailView.tsxにisDesktopMode propを追加し、既存の編集ボタン・編集フォームをデスクトップモード限定でUI表示するようretrofitしたが、対応するサーバー側API(app/api/membersのPUT)にはrequireDesktopMode()相当のガードが追加されなかった。middleware.tsもアクセス制限を持たないため、本番Web環境に対して直接PUTリクエストを送ればメンバーデータを書き換えられる状態が長期間残っていた(PR #1602のレビューで発覚)。 - 一方、この時点以降に新規実装された編集系API(link-editor・youtube-link・festivals・lives・setlists・radar-chart-timeline-config)は、実装時点で「デスクトップ限定」が既に確立済の設計パターンだったため、いずれも最初からrequireDesktopMode()が組み込まれていた。ガード漏れが起きたのは「アクセス制御という概念が存在する前から存在していた、最も古い編集機能」だけだった。 - 横展開の見つけ方: app/api/配下の全ルートファイルを洗い出し、書き込み系メソッド(PUT/PATCH/POST)ごとにrequireDesktopMode()の有無を確認した。書き込み系だがガードが無いルートがもう1つ見つかった(PUT /api/member-map-layout)が、対応する画面設計書(member-map-screen-design.md)に「デスクトップモード限定」の記載が無く、誰でも操作・保存できる共同編集的な機能として意図的に設計されていたため対象外と判断した。同種の調査を行う際は、ガードの有無だけでなく設計書の記載と突き合わせて意図的な公開か見落としかを判別すること。 - 対策: app/api/membersのPUTに他の編集系APIと同じrequireDesktopMode()ガードを追加。既存機能に新しいアクセス制御要件を導入するときは、UI側の表示制御を追加した時点で、対応するAPIルート側の認可実装も必ずセットで見直す。 - 追記(実装時に判明): requireDesktopMode()はこれまでlink-editor・youtube-link・festivals・lives・setlists・radar-chart-timeline-configの6ファイルに同一内容のクローンコードとして存在しており、共通関数としてlib/に切り出されていなかった。これは今回のガード漏れの一因でもある。クローンコードは追加自体を忘れる・一部のクローンだけ書き換えを反映し忘れる、といった漏れが構造的に起きやすい。今回の修正でlib/desktop-mode.tsにrequireDesktopMode()を切り出し、app/api/membersを含む7ファイルすべてをそこからのimportに統一した(MorningStatusApp#1603)。同種のクローンコード化したガード関数を見つけたら、修正のついでに共通化まで行うことを検討する。

  44. @idフィールドの値をPrisma updateでリネームする場合、単純なFK(子テーブルの主キーに親IDを文字列連結していない設計)であればON UPDATE CASCADEにより子テーブルの外部キー列は自動追従する。ただしリネーム先の値が既に別レコードとして存在する場合は一意制約違反(P2002)になるため、事前にexistsチェックするか例外を捕捉して収束させる必要がある(reconcile-manual-releases.ts、MorningStatusApp #1594) - 手動登録リリース(Release.releaseIdがmanual-<slug>形式)をMusicBrainz登録後のMBIDに「洗い替える」機能を実装する際、Release.releaseIdはTrack.releaseIdからON DELETE RESTRICT ON UPDATE CASCADEで参照されている(tracks_releaseId_fkey)ことをマイグレーションSQLで確認した。prisma.release.update({ where: { releaseId: oldId }, data: { releaseId: newId } })を実行するだけで、紐づくTrack行のreleaseIdはDB側のカスケードにより自動的に新IDへ書き換わり、アプリケーション側でTrackを個別に更新するコードは不要だった。 - この挙動は、知見#111(Setlist.setlistIdのように子テーブルの主キー自体に親IDを文字列として埋め込んでいるケースはON UPDATE CASCADEの対象にならない)とは対照的なケースである。判別基準は「子テーブルの外部キー列が単なるFK専用カラムか、それとも子テーブル自身の主キー(の一部)を兼ねているか」。前者ならカスケードが素直に効き、後者は自前の追従更新処理が必要になる。 - リネーム先の値(本ケースではMBID)が既に別のRelease行として存在する場合、Release.releaseIdの一意制約違反でupdateが失敗する。PrismaはこれをPrisma.PrismaClientKnownRequestError(err.code === 'P2002')として送出するため、try/catchで捕捉して「衝突」として扱い、対象をスキップしてログ出力する設計にした。事前にfindUniqueで存在確認する方式でも同じ判定はできるが、TOCTOU(確認後に別プロセスが書き込む)を考えず単純化するなら、updateを実行して例外を捕捉する方が確実。 - 実装検討時は「洗い替え先が既に存在するケースへの名寄せ・強制削除」まで作り込みかけたが、実データ調査(手動登録9件)で該当ケースが0件だったため、衝突検出→スキップのみに留めた。発生確率が未知数の複合ケースに対しては、まず実データを確認してから対応の重さを決めるとよい。

  45. MusicBrainzのタイトル表記揺れはハイフンだけでなく三点リーダーにも及ぶ。実装完了後に実データへ--dry-runを実行して初めて発覚した(normalizeHyphens→normalizeTitlePunctuationへの改称、MorningStatusApp #1594・#1553) - reconcile-manual-releases.tsの実装・テストが完了した後、実データに対して--dry-runを実行したところ、手動登録リリース「Lonely…But not Alone」(ASCIIピリオド3つ)が、MusicBrainz側に同一日付・実質同一タイトルで既に登録されていた(「Lonely… But not Alone」、U+2026三点リーダー+直後にスペース)にもかかわらず、完全一致照合でスキップされていた。 - ハイフンの表記揺れ(#1553)はMusicBrainz APIレスポンスの構造的な癖として事前に把握できていたが、三点リーダーの表記揺れは机上の設計検討では想定できず、実データに対する動作確認(--dry-run)を実際に行って初めて発見した。ユニットテストのフィクスチャは自分で書いた文字列のため、実在するデータ特有の表記揺れは原理的に検出できない。 - 対策: normalizeHyphens()をnormalizeTitlePunctuation()に改称し、ハイフン正規化と三点リーダー正規化(…\s* → ...。直後のスペースも合わせて除去しないと「Lonely… But not Alone」のようにスペース差だけが残ってしまう)を1つの関数に統合した。 - 教訓: 外部データソース(MusicBrainz等、非日本語話者を含む不特定多数が編集するデータベース)とのタイトル完全一致照合を実装する場合、実装・ユニットテストの完了をもって終わりとせず、実データに対する動作確認を通じて初めて見つかる表記揺れが他にもある前提で臨むこと。今回は氷山の一角が2件(ハイフン・三点リーダー)見つかったが、波ダッシュ(〜/~)・全角スペース等、同種の揺れが将来また見つかる可能性がある。

  46. recharts(v3系)のTooltipのformatter/labelFormatterpropsに明示的な引数型注釈((value: number) => ...)を付けると、ライブラリ側のジェネリック型Formatter<ValueType, NameType>と噛み合わずTS2322になる(MorningStatusApp #1595) - <Tooltip formatter={(value: number) => [\${value}歳`, ‘平均年齢’]} />のようにvalueへnumber型注釈を書くと、ValueType(number | string | Array<number|string> | undefinedのような広い型)を要求するrechartsのFormatter型との構造的な不一致でコンパイルエラーになる。valueは実際には常に数値が渡ってくる場面であっても、型注釈を書いた時点でライブラリの期待するシグネチャと不一致になる。 - **対策**: formatter/labelFormatterの引数には明示的な型注釈を付けず、TypeScriptにコールバックの引数型をrechartsの型定義から推論させる((value) => …)。本リポジトリでrechartsを使った既存グラフ(DiscographyStatusChart.tsx等)はいずれもformatter/labelFormatterを使っておらず、この制約は本IssueでLineChart`にTooltipのカスタムフォーマットを追加した際に初めて発覚した。

  47. 新規のVercel Blob「少数マスタデータ」ファイルを追加する場合、専用の環境変数(XXX_BLOB_URL)を都度発行する方式ではなく、@vercel/blobのlist({ prefix })でBlobを動的に検出する方式(radar-chart-timeline-config.jsonが先例)を使うと、本番環境への環境変数登録が一切不要になる(MorningStatusApp #1487) - units.json・members.json等の初期からある少数マスタはprocess.env.UNITS_BLOB_URLのような専用環境変数でURLを固定する方式を採っている。この設計を素朴に踏襲し、新規追加するtiktok-official-accounts.json・instagram-official-accounts.jsonにもTIKTOK_OFFICIAL_ACCOUNTS_BLOB_URLのような環境変数を新設する設計で一度ユーザーに提示したが、承認後にapp/api/radar-chart-timeline-config/route.ts(#1477で先に実装済)が既にlist({ prefix: BLOB_PATH })でBlobを都度検出する方式を採用していることに気づき、設計を差し替えた。 - list()方式はBLOB_READ_WRITE_TOKEN(既存・全環境で設定済)のみで動作し、addRandomSuffix: falseでput()する限りファイルパスから決定的にBlobを発見できる。新しいBlobファイルを追加するたびに「ローカルの.env.localに追記」「本番Vercelにも環境変数を登録してもらうようユーザーに依頼」という2箇所の手作業が発生する環境変数方式に比べ、コード変更だけで完結し外部作業(本番環境変数登録)が一切不要になる。 - 教訓: 新規Blobファイルの実装前に、同種の「少数マスタデータをBlobで管理する」既存実装(units.jsonのような古い先例だけでなく、radar-chart-timeline-config.jsonのような比較的新しい先例)を横断的に確認し、より新しい・より良いパターンがないか確認してから設計を確定させる。今回は设計承認後に気づいたため、設計書を差し替える手戻りが発生した(ステップ11-bの「設計書との整合性の最終確認」で対応)。

  48. 静的定数(constants/*.ts)をVercel Blobベースの非同期取得に置き換える移行では、その定数を暗黙of importして実データに依存しているテストファイルが、モックなしで壊れずに「間違った空データ」を返して静かに失敗する(MorningStatusApp #1487) - OFFICIAL_TIKTOK_ACCOUNTS・OFFICIAL_INSTAGRAM_ACCOUNTSという同期定数を、getTikTokOfficialAccountsFromBlob()・getInstagramOfficialAccountsFromBlob()という非同期Blob取得関数に置き換えたところ、この定数を直接importしていた10箇所以上のテストファイル(NewPostsSection.test.tsx・app/tiktok/page.test.tsx・lib/blob.test.ts等)が、@vercel/blobのlist()を全くモックしていなかったため、テスト実行時にBLOB_READ_WRITE_TOKENが無い環境でlist()が失敗し、新関数がフェイルセーフの空配列[]を返すようになった。多くのテストはこれによって「モーニング娘。公式」等の文字列が全く出力されなくなり、明確なエラーではなくtoHaveLength(2)が0になる、toContainが失敗する、といった形でしか気づけなかった。 - 横展開の見つけ方: 削除予定の定数ファイルへの参照をgrep -rnで全数洗い出し、importだけでなく型としての参照(import type)も含めて、値の使用箇所とテストの両方を1件ずつ確認した。最後に定数ファイル自体を削除し、コンパイルエラーが0件になることで参照漏れがないことを保証した(削除は移行の最終確認手段として有効)。 - 対策: 定数→Blobの移行では、(1) 新しいBlob取得関数の実装、(2) 全ての本番コード参照の置き換え、(3) 全ての関連テストファイルのモック追加・調整、(4) 旧定数ファイルの削除とコンパイル確認、の順で1つずつ潰す。特に(3)を後回しにすると、テストは「エラーで落ちる」のではなく「意図と異なる空データで通り続ける・失敗する」ため、変更に気づかれにくい。

  49. 表示用に整形した非ゼロパディング日付文字列("YYYY/M/D")をソートキーに再利用すると、同一年内で月日の桁数が異なる場合に文字列比較で順序が崩れる(app/members/[id]/page.tsx、MorningStatusApp #1624) - 「グループの最初の公演日で降順ソート」を実装する際、表示用にformatLiveDateFull()で生成した"YYYY/M/D"文字列(月日は非ゼロパディング)をそのままソートキーとしてlocaleCompareで比較していた。"2026/4/11".localeCompare("2026/12/3")のように月・日の桁数が1桁/2桁で揃わないと、文字列としての辞書順と実際の時系列が食い違う。 - 同じ機能ドメイン(ツアー・ライブのグループ化)を扱うlib/live-helpers.tsのgroupLivesByYearAndTour()は、firstDateをDate型のまま保持しgetTime()で比較しており、こちらは正しく動作していた。既存の類似実装を横断的に確認すれば、独自に非ゼロパディング文字列でソートキーを作る前に気づけた可能性がある。 - 対策: 日付をソートキーに使う場合、データソースが元々ゼロパディング済のISO文字列(releaseDate・joinDate等)ならそのままlocaleCompareでよいが、表示用に独自フォーマットへ変換した文字列をソートキーに転用しない。変換前の生のDateオブジェクトを別途保持してソートし、ソート確定後に表示用文字列へ変換すること。

  50. gitignore対象の生成物にtsconfig(extends/include)が依存していると、その生成物を作るコマンドを一度も実行していない環境でのみ型チェック・テストが失敗する再発パターン(note #30・#32、MorningStatusApp#1629が2件目) - 1件目(note #30): desktop/tsconfig.jsonが./.hutch/devkit/tsconfig.jsonをextends。desktop/.hutch/はbunx electrobun prepare実行時のみ生成、bun installだけでは生成されない - 2件目(note #32、MorningStatusApp#1629): tsconfig.scripts.jsonがincludeにnext-env.d.tsを指定。このファイルはnext dev/next build実行時のみ生成される - 両者に共通する構造: (1) 対象の生成物は.gitignore対象でbun installだけでは作られず、専用コマンドの実行が必要 (2) その生成物が既に存在する環境(過去に一度でも該当コマンドを実行したことがある開発者のローカル)では再現せず、CIやフレッシュなcloneでのみ顕在化する (3) 実際に出るエラー([TSCONFIG_ERROR] Tsconfig not found・No overload matches this call等)は、いずれも「このコマンドを実行すれば直る」という情報を一切含まず、発見にはtsconfigの参照先を実際に遡る調査が必要だった - 設計原則(「都度確認する」ではなく設計時点で回避する): 新しいtsconfigを書く時点で、extends/includeが.gitignore対象の生成物を参照する設計をデフォルトの選択肢にしない。型情報の共有が目的なら、生成物への参照ではなく、必要な型宣言だけを複製した自己完結のアンビエント.d.tsをリポジトリに直接コミットすることを優先する(#1629のscripts/global.d.tsが実例)。生成物への依存が構造上避けられない場合(.hutch/devkitのようにビルドツールチェーン自体がAPI型定義一式を生成し、複製が非現実的なケース)に限り、tsconfig作成と同時にCI・ローカルセットアップ手順の両方へ生成コマンドを明示的に組み込む(「後で気づいたら直す」ではなく、tsconfigを書く作業の一部として完了させる) - バックストップ(構造的な検知): 上記の設計配慮を徹底しても見落としは起こりうるため、bun run lint・bun run type-checkをCIのtestジョブで必ず実行する状態にしておく。docs/design/common/test-design.mdには元々この方針が明記されていたが実装が追随しておらず、MorningStatusApp#1629の調査を機に.github/workflows/deploy-vercel.ymlへ追加した。CIは常にクリーンな環境(生成物なし)で走るため、この種の設定ミスを人の記憶に頼らず自動検知できる

  51. 既存のServer Componentを"use client"化すると、それが直接importしている子コンポーネントの「既存の」importチェーンがサーバー専用モジュールに触れていないか再確認が必要(実装#47の続き、MorningStatusApp#1569) - components/NewPostsSection.tsxをTanStack Query導入に伴いServer Componentから"use client"へ変更した際、子コンポーネントTikTokNewPostCard.tsxがisTikTokThumbnailValidを@/lib/tiktokからimportしていた(lib/tiktok-utils.tsからの単純な re-export)。NewPostsSectionがServer Componentだった間は問題にならなかったが、"use client"化した瞬間にTikTokNewPostCardもクライアントバンドルの一部として扱われ、@/lib/tiktokのトップレベルimport { prisma } from '@/lib/prisma'がクライアント側で評価され、テスト実行時にDATABASE_URL 環境変数が設定されていませんエラーで落ちた。 - 実装#47は「新しいimportを追加するとき」のチェックを求めているが、本件は新規importではなく、親を"use client"化したことで既存の子コンポーネントのimportが新たにクライアント境界の内側に入った再発パターンだった。実際、TikTokNewPostCard.tsxのトップレベルimportを1段確認するだけで@/lib/tiktok→prismaの混入は見つけられたはずで、実装#47のチェック手順自体は有効だったが、「親コンポーネントの"use client"化」もチェックのトリガーに含める必要がある。 - 対策: isTikTokThumbnailValidのimport元を、prismaに依存しない@/lib/tiktok-utilsに直接変更した(@/lib/tiktokは既にこの関数をlib/tiktok-utilsからre-exportしているだけだった)。 - 教訓: Server Componentを"use client"化する際は、「新しいimportの追加」だけでなく「既存の子コンポーネント全ての既存importチェーン」も実装#47と同じ基準(トップレベルimportを1段確認)で再チェックする。特に、ある関数が複数のファイルから re-export されている場合(lib/tiktok.tsがlib/tiktok-utils.tsの関数を re-export する等)、import元をサーバー専用の集約ファイルではなく、実際にクライアント安全な定義元のファイルに揃えることで、この種の意図しない混入を構造的に避けられる。

  52. 正規化されたjoin用IDに、対応する実体(Memberレコード等)が存在しない疑似ID(公式アカウントIDなど)が混在する場合、表示名は非正規化フィールドとして保持する必要がある(MorningStatusApp#1569) - Unified Feed Item化(types/feed.ts)でInstagram投稿の型を設計した際、memberIdからmembers配列をMapで引いて表示名を解決する方式(Ameba・TikTok・YouTubeで採用)をInstagramにもそのまま適用し、当初はDailyNewPostが持っていたmemberNameフィールドを不要と判断してドロップした。 - しかし公式Instagramアカウント(「モーニング娘。公式」)の投稿はmemberIdに公式アカウントの疑似ID(例: morningmusume-official)が入るが、この疑似IDに対応するMemberレコードはmembers配列に存在しない(公式アカウントはメンバーではないため)。memberIdからのjoinに一本化した結果、公式アカウントの投稿だけ表示名が空文字になる回帰バグを作り込んだ(テストのフィクスチャに実在しないメンバーレコードを追加してごまかす形で書いてしまったが、そのフィクスチャが原因でブロック分類テストが即座に失敗し、根本原因の発見につながった)。 - 教訓: 既存の非正規化フィールド(memberName等)を「正規化されたjoinで代替可能」という理由だけで削除する前に、そのIDフィールドが指す値の集合が、joinで引く先のテーブル(ここではmembers)の主キー全集合と完全に一致するか(=実在しないIDを指すケースが無いか)を確認すること。「公式アカウントIDをmemberIdの型に間借りさせる」ような設計(本件はAmeba/TikTok/YouTube側の既存設計を踏襲したもの)がある場合、その“疑似ID”はjoin先に実体を持たないため、表示に必要な情報は疑似ID発行元(ここではBFF層、lib/feed/*.tsのマッピング処理)でitem自身に埋め込んでおく必要がある。

  53. queryClient.prefetchQuery() はqueryFnが投げたエラーを内部で握りつぶし、失敗時も例外を伝播させず正常終了する(MorningStatusApp#1640) - /members(メンバー一覧)画面をTanStack Query化する際、app/members/page.tsxのサーバー側prefetchを#1569の他画面と同じawait queryClient.prefetchQuery({queryKey, queryFn: getMembersFromBlob})パターンで実装したところ、getMembersFromBlob()がMEMBERS_BLOB_URL未設定やBlob取得失敗で例外を投げても、MembersPage()自体は例外を伝播させず正常にHTMLを返してしまう回帰が見つかった(テストでvi.fn().mockRejectedValue(...)を使いawait expect(MembersPage()).rejects.toThrow(...)を書いたところ、実際にはresolveしてしまい判明)。 - 原因はqueryClient.prefetchQuery()自体の仕様: TanStack Queryは「プリフェッチは失敗してもクライアント側のuseQueryが改めて取得を試みればよい」という設計思想のため、prefetchQueryはqueryFnのエラーを内部で捕捉するだけで呼び出し元には再スローしない。エラー時はキャッシュに何も積まれないまま(dehydrate()結果のqueriesが空配列)resolveする。 - Issue #1569(トップ画面の新着投稿セクション)では同じprefetchQueryパターンを使ったにもかかわらずこの問題が顕在化しなかった。理由はlib/feed/*.tsの各関数(getAmebaFeedItems等)が内部でtry/catchし失敗時に空配列を返す設計になっており、queryFn自体がそもそも例外を投げない実装だったため。一方getMembersFromBlob()(lib/blob.ts)は他の直接呼び出し元(app/page.tsxのallMembers等)と挙動を合わせるため意図的に例外を投げる設計になっており、prefetchQueryに渡すとその「投げる」契約が壊れる。 - 対策: 呼び出し元のページ全体を失敗させたい(=元の直接awaitと同じフェイルラウド挙動を維持したい)queryFnをプリフェッチに使う場合は、prefetchQueryではなく「直接awaitしてからqueryClient.setQueryData(queryKey, result)でキャッシュに積む」方式にする。await部分で例外が投げられればページ全体が失敗し、成功時はsetQueryDataがprefetchQuery成功時と同じキャッシュ状態を作るため、クライアント側のuseQuery・dehydrate/HydrationBoundaryとの組み合わせに違いは生じない。 - 教訓: prefetchQueryを新しい画面に導入する際、渡すqueryFnが「失敗時に空配列/nullを返す」フェイルセーフ設計か、「失敗時に例外を投げる」フェイルラウド設計かを必ず確認すること。後者の関数をprefetchQueryにそのまま渡すと、元の直接呼び出しコードが持っていたエラーハンドリング契約(呼び出し元を失敗させる)が黙って失われる。判定に迷う場合は、失敗ケースを意図的に発生させるテスト(mockRejectedValue等)を先に書いてrejects.toThrowを確認するとよい。

  54. 状態の判定を「フィールドの有無」だけで設計すると、予定(未来日)を持つデータで誤判定する。設計時に実データで境界を確認する(MorningStatusApp#1650、世代マトリックス) - 症状: 画面設計書で「OG=卒業日(gradDate)が設定されている、現役=null」と決めて実装したところ、卒業が決まっているだけでまだ現役のメンバー(status は Active、gradDate は数か月先)がOGと表示された。ユーザーの目視で発覚した - 原因: gradDate は「卒業した日」だけでなく「卒業が決まっている日」も保持する。有無だけで判定すると、予定と実績を区別できない。実装時の報告で「未来日の卒業日があるかは確認していない」と書き留めたまま、実データで確かめなかった - 対策: 日付を持つフィールドで状態を判定する設計では、「未来日が入るか」を設計時に実データで確認する。入りうる場合は、有無ではなく「現在の日付との前後」で判定する(今回は「gradDate が現在の日付(JST)より後なら現役」)。判定の基準日はテストで固定して渡せるよう、関数の引数にする - 併せて、実装時に「実データで確認していない前提」を報告に書き留めた場合は、その場で読み取り専用の確認(今回は members.json の取得)を済ませる。後回しにすると、ユーザーの目視が最初の検出になる

  55. prisma format は、変更していない別のモデルの空白も整形し直す。スキーマを変更したら、差分が意図したモデルだけかをgit diffで確認する(MorningStatusApp#1545) - 症状: Festivalに2カラムを追加してbunx prisma formatを実行したところ、無関係なEventSongモデルの1行(event Event @relation(...))の空白も整形され、差分に混ざった。過去の変更で整形されないまま残っていた行が、今回のformatで初めて整形されたもの - 対策: prisma formatの後にgit diff -U0 -- prisma/schema.prismaで、意図した変更以外の行がないかを確認し、あれば元に戻す(PRの差分を1つのIssueの変更に限るため)。マイグレーションSQLは、直前のスキーマ(git show HEAD:prisma/schema.prisma)とのmigrate diffで生成するため(実装#122)、空白だけの差分はSQLに影響しない

  56. “HH:MM” の時刻文字列を Date に変換して保存する入力(API・登録スクリプト)は、形式(数字2桁:数字2桁)だけでなく、範囲(時 00〜23・分 00〜59)まで検証する(MorningStatusApp#1545) - 理由: 25:99 のような範囲外の値は形式の検証を通過し、Invalid Date になる。API では検証エラー(400)ではなく、DB 書き込み時のエラー(500)になり、スクリプトでは書き込みの途中で失敗する - 対策: 正規表現を ^([01]\d|2[0-3]):[0-5]\d$ にする(フェスの PATCH /api/festivals/[id] と、フェス・ツアー登録バッチのフェス入力に適用した)。実際のサーバーに time: '25:99' を送り、400 が返ることをPlaywrightで確認できる(不正な値なので書き込みは起きない) - 補足: 既存の PATCH /api/lives/[id] は ^\d{2}:\d{2}$ のみを検証している。同じ問題がある可能性が高いが、コードを読んだだけで、実行では確認していない(今回の対象外のため未修正)

  57. 既存の大規模コンポーネントにuseQueryを追加すると、それを直接render()している全テストがQueryClientProvider不在で失敗する。global.fetchをモックしている周辺テストは、新たな背景フェッチが呼び出し順の前提(mock.calls[0]等)を壊すことにも注意(MorningStatusApp#1657) - 症状: MemberDetailView(関連ライブ・ご当地ライブの2セクションが依存)をServer Component側の直接計算からRoute Handler + useQuery化したところ、MemberDetailView.test.tsx内のrender(<MemberDetailView .../>)呼び出し145件全てが「No QueryClient set」で失敗した。加えて、保存API呼び出しのfetch引数をmock.calls[0][1].bodyで検証していた既存テスト9件が、useQueryが発火する関連ライブ取得の背景フェッチがcalls[0]に割り込んだことで壊れた - 原因: useQueryはReactツリー内のどこかにQueryClientProvider祖先が必須。対象コンポーネントが「既にクライアントコンポーネントだから追加のuseQueryは安全」という判断は誤りで、テスト側のレンダリング方法(Provider経由かどうか)が別途問題になる。またglobal.fetchを丸ごとモックしているテストは、モックが呼び出し順不問で全リクエストに応答するため、新しいuseQueryが発火するfetchも同じモックにヒットし、意図しない呼び出しがmock.callsに混入する - 対策:

    1. render(<Target .../>)を直接呼んでいる既存テストが多数ある場合、個別に書き換えずQueryClientProviderでラップする共通ヘルパー(例: renderTarget(ui, seedData))を1つ追加し、機械的にrender(→renderTarget(へ置換する(本件は145箇所を正規表現一括置換、アサーション自体は無変更で全件パス)
    2. ヘルパーのQueryClientはdefaultOptions: { queries: { staleTime: Infinity } }にし、対象コンポーネントが参照するqueryKeyに既定で空配列等をシードしておく。staleTime: Infinityによりマウント時の背景再フェッチが起きなくなり、global.fetchモックに無関係な呼び出しが混ざらない(既存のmock.calls[0]前提のテストを直す必要がなくなった)
    3. ヘルパーの引数は「レンダリング対象のReact要素」+「シードするデータ」のみに絞り、queryKey導出に必要な値(メンバーIDなど)はui.propsから読み取ると、呼び出し側のAPIを最小限に保てる - 教訓: 大規模コンポーネントへのuseQuery導入は、機能追加そのものよりテストインフラ(既存render呼び出し・fetchモックの前提)への影響調査が本番。着手前に「このコンポーネントを直接renderしている既存テストは何件あるか」「グローバルなfetchモックに依存したテストがないか」をgrepで確認すること
  58. 既存クラス(app/globals.cssのレイヤー外CSS)をPanda CSSのglobalCss(レイヤー内)へそのまま移行すると、CSS仕様上「レイヤー外は常にレイヤー内より優先」のため優先度が逆転して負ける。加えてTailwind CSS v4は組み込みの.containerユーティリティを持ち、同名クラスは移行前後で衝突の有無が変わる(MorningStatusApp#1655) - 症状: 共通レイアウトの最大幅(1200px)を実現していた独自クラス.containerをそのままの名前でPandaのglobalCssに移したところ、幅1920pxで通常画面のmax-widthが意図しない1536pxになった。原因はTailwind v4がソース内のcontainerトークンを検出し組み込みのレスポンシブ.containerユーティリティ(@layer utilities)を自動生成するため。移行前は独自.containerがレイヤー外(unlayered)にあり常に最優先だったため問題にならなかったが、PandaのglobalCss(baseレイヤー)に移した時点で、より優先度が高いutilitiesレイヤーの組み込み.containerに負けるようになった - .app-containerに改名して名前衝突は解消したが、改名後もpadding/marginが0になる別の不具合が残った。原因はglobals.cssの* { padding: 0; margin: 0; }という、このプロジェクト自身が持つレイヤー外リセットCSS。レイヤー外CSSは詳細度に関わらず常に最優先されるため、PandaのglobalCss(レイヤー内)に置いた指定はTailwindの有無と無関係にこのリセットに負ける - 対策: 移行前に、対象クラスが「①フレームワーク組み込みのユーティリティ名と衝突しないか」「②移行先のレイヤーより優先度の高いレイヤー外CSSが既存コードに無いか」の2点を確認する。②が該当する場合、そのクラスのPanda化は見送り、レイヤー外の生CSSとして維持する(クラス名の衝突回避のための改名だけは行ってよい)。Playwrightで実際のgetComputedStyleを計測して検証すること。テキストベースの理論的な優先順位判断だけでは、①はクラス名生成の有無まで、②は既存CSSの網羅的な把握まで追いきれず見落としうる - 参考: CSSの@layerは同名レイヤーであれば宣言位置に関わらず1つに統合され、初出のレイヤー名だけがその位置に新規追加される。詳細はPanda CSS 設計書 §3を参照

  59. Hutch(Electrobun 2.0.1のビルドツールチェーン)が内部的に起動するpowershell.exeは、実行元シェルの実行ポリシー設定を引き継ぐため、環境によってReleaseCommandFailedで失敗することがある(MorningStatusApp#1599、[118]のtar解決問題と同系統の事象) - 症状: Windows実機でbun run build:installer(setup/build-installer.ps1経由)を実行すると、インストーラ生成の最終段階(配布用zip作成)でhutch electrobun: command failed: powershell.exe → error: ReleaseCommandFailedとなり失敗する。ただしMorningStatusApp-Setup.exe本体は失敗より前の時点で生成完了しており、実害はzip化のみに限定される - 原因: Sysinternals Process Monitorでの実プロセス起動キャプチャにより、Hutchがzip化のためpowershell.exe -NoProfile -NonInteractive -Command "Compress-Archive ..."を子プロセスとして起動していることは確認できたが、この時点(2026-09-06)では具体的な失敗理由の特定には至らなかった。後日、実行ポリシーの制限を疑いSet-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Processを試したところビルドが成功したことから、この呼び出しが実行元シェルのPowerShell実行ポリシー(Execution Policy)設定に依存していたと判明した。報告者の通常の対話シェルでは失敗する一方、より緩いポリシーの環境(自動化ツール経由の実行等)では成功していたのは、この設定差によるものだった - 対策: build-installer.ps1の冒頭でSet-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Forceを実行し、実行元シェルの設定に依存せず一貫して成功するようにする。-Scope Processは現在のプロセスとその子プロセスにのみ有効で、管理者権限は不要かつスクリプト終了後に元の設定へ影響を残さない - 教訓: [118]と同様、Hutchが内部で起動する子プロセス(tar・powershell.exe等)の挙動は実行元シェルの環境設定(PATH解決・実行ポリシー等)に依存する。Hutch関連のビルド失敗を調査する際は、まずコード変更の有無ではなく実行環境の差分(シェルの種類・設定)を疑うこと

  60. PandaのカスタムキーフレームはTailwindのanimate-*ユーティリティの代替になる。css()のブレークポイントキーはCLAUDE.mdのモバイルファースト規約をそのまま満たす(MorningStatusApp#1656) - Tailwindのanimate-pulse(読み込み中スケルトン表示用)を撤去するにあたり、panda.config.tsのtheme.extend.keyframesにTailwind相当のキーフレーム(pulse: { "50%": { opacity: "0.5" } })を追加し、css({ animation: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite" })で参照することで、既存の見た目を変えずに置き換えられた。bunx panda codegen実行後、生成CSSに@keyframes pulse{50%{opacity:.5}}と対応する.anim_*ユーティリティクラスが出力されることを確認済 - 既存のstyle={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(Npx, 1fr))" }}(インラインstyle)をPandaのcss({ display: "grid", gridTemplateColumns: "1fr", md: { gridTemplateColumns: "repeat(auto-fill, minmax(Npx, 1fr))" } })に置き換えると、生成CSSは.d_grid{display:grid}.grid-tc_1fr{grid-template-columns:1fr}と@media screen and (min-width:48rem){.md\:grid-tc_repeat(...)...}に分解される。mdはpanda.config.tsのデフォルトブレークポイント(768px)で、CLAUDE.mdの「CSS Gridはモバイルファーストで実装する」規約(デフォルト1列、768px以上で複数列)をそのまま満たす形になる - 対策: Tailwindのanimate-*系ユーティリティをPanda化する際は、まずtheme.extend.keyframesに同名・同内容のキーフレームを定義してからcss()のanimationプロパティで参照する。既存のauto-fill, minmax()グリッドをモバイルファースト化する際は、デフォルト値を1frにした上でmd(またはプロジェクトのカスタムブレークポイント)キーの中に元のauto-fill, minmax()をそのまま移すだけでよく、minmax()の最小幅は画面ごとに異なる既存値を踏襲する - 検証方法: tsc・eslint・vitestはCSS生成そのものを検証しないため、bun run build実行後に.next/static/chunks/*.cssを実際にgrepし、意図したキーフレーム・メディアクエリが出力されているか、撤去したはずのTailwindクラス(.animate-pulse等)が残っていないかを確認すること

  61. app/globals.cssのレイヤー外リセットを@layer resetへ移すと、PandaのglobalCss(baseレイヤー)が優先されるようになる。Panda globalCssはbaseレイヤーに出力される(MorningStatusApp#1667) - #1655時点では、app/globals.cssの* { padding: 0; margin: 0; }がレイヤー外(unlayered)にあり、CSS仕様上どの@layer内のCSSよりも常に優先されるため、PandaのglobalCssに置いた.app-containerのpadding/margin指定が詳細度に関わらずこのリセットに負けていた([144]参照) - #1656でTailwindを撤去したことで、app/globals.cssの@layer reset, base, tokens, recipes, utilities;宣言がPanda単独で完全に確定した(Tailwindが別途base・utilitiesを宣言することによる層の混在が無くなった)。この状態で* {...}リセットを@layer reset { * {...} }へ明示的に移したところ、宣言順(resetが最初=最も低優先度)により、PandaのglobalCssが出力されるbaseレイヤーがresetレイヤーより優先されるようになり、.app-containerのPanda化が可能になった - baseレイヤーへの出力先はPandaの型定義(@pandacss/types)には明記されていないため、bun run build後の生成CSS(.next/static/chunks/*.css)を実際に確認して特定した(@layer base{:root{--made-with-panda:"🐼"}.app-container{...}}という出力から判明) - 対策: プロジェクト自身が持つレイヤー外のグローバルリセットをPanda管理下のCSSと共存させたい場合、リセットを@layer reset { ... }のように明示的にレイヤー内へ移すことで、宣言順に基づく優先度制御が可能になる。ただし、他の既存の unlayered CSS(CSS Modules・独自クラス等)は、レイヤー内に移した後もそれらが常にレイヤー外=最終レイヤー相当のまま残るため優先度は変わらず、移行の影響はリセット対象のプロパティ(今回はpadding/margin)のみに限定される - 検証方法: Playwrightで対象要素のgetComputedStyle(padding等)とgetBoundingClientRect(margin: autoによる中央寄せの実際の位置)の両方を計測すること。getComputedStyle().marginLeftはmargin-inline: auto指定時に0pxと報告されることがあり(ブラウザの論理プロパティ解決の癖)、marginLeft単体では中央寄せの成否を判断できない。getBoundingClientRect().leftで実際の描画位置を確認する

  62. styled-system/はGit管理外のため、ローカルで一度でもbunx panda codegenを実行すると、以後のローカルビルドはstyled-systemが存在する前提で常に成功してしまい、Vercel等のクリーンな環境でのビルド失敗(Module not found: Can't resolve '@/styled-system/css')に気づけない(MorningStatusApp#1656) - 症状: import { css } from "@/styled-system/css"を使うファイルを追加したPR(MorningStatusApp#1667・MorningStatusApp#1666)で、Vercel Preview BuildがModule not found: Can't resolve '@/styled-system/css'で失敗した。ローカルではbun run buildが問題なく成功していたため、レビュー時点まで気づかなかった - 原因: panda.config.tsのoutdir: "styled-system"はGit管理外(.gitignore対象)で、bunx panda codegenを手動実行するか、@pandacss/dev/postcssのPostCSSプラグインがビルド時に暗黙生成する。ローカル環境では過去のセッションで既にbunx panda codegenを実行済だったため、styled-system/が実体として存在し続け、bun run build(prisma generate && next build)を実行するだけで問題なくビルドできていた。一方Vercelは毎回クリーンな環境(git clone + bun install)からビルドするため、styled-system/が一度も生成されておらず、webpackがモジュール解決する時点でJSファイルが存在せず失敗する - 再現方法: ローカルでもrm -rf styled-system .next && bun run buildのようにstyled-systemディレクトリ自体を削除してからビルドすると、Vercelと同じModule not foundエラーを再現できる。逆に言えば、styled-systemを一度でも生成した後のローカル環境では、削除しない限りこの種の不具合を検出できない - 対策: package.jsonのbuildスクリプトにpanda codegenを明示的に追加する("build": "prisma generate && panda codegen && next build")。PostCSSプラグインによる暗黙生成はJS側のwebpackモジュール解決より後・並行で走る可能性があり信頼できないため、CLIで明示的に事前生成する方が確実。Panda CSS用の公式Next.jsプラグインパッケージは存在しない(@pandacss/devのPostCSS統合のみ)ため、CI/デプロイ環境での確実な生成はビルドスクリプト側で担保する - 教訓: .gitignore対象の生成物に依存するコードを追加した際は、ローカルの使い回し環境ではなく、生成物を一度削除してからのクリーンビルドで動作確認すること。この教訓は「インフラ・設定」カテゴリの#30(desktop/.hutch/の生成タイミング)とも共通する - 続報: buildスクリプトの修正だけでは不十分だった。GitHub Actions(.github/workflows/deploy-vercel.ymlのtestジョブ)のbun run test(vitest)でも同じクリーンチェックアウト環境の問題が再現し、Failed to resolve import "@/styled-system/css"で失敗した。bunx prisma generateの直後にbunx panda codegenを実行するステップを追加して解消した。build・test双方のスクリプト/CIステップで、styled-systemに依存する箇所ごとに個別に生成ステップが必要な点に注意する

  63. .figma/ui-structure.jsonは実装コードの変更に自動追従しない。ページ単体の追加漏れだけでなく、既存登録済ページの内容が後続のリファクタで陳腐化していないかも確認すること(MorningStatusApp#1544) - 発端: 未登録6画面(ラジオ・都道府県別公演・フェス一覧等)の登録漏れをIssue #1544で解消する作業中、直前にマージされたグリッドのPanda CSS化(MorningStatusApp#1656)に自分(実装者)は気づかず、新規登録した画面だけそのパターンに追随し、既存の登録済ページ側は無反応のまま作業を終えようとしていた。ユーザーから「新規追加分だけPanda CSSに追随していて、他は陳腐化して残っていないか」と指摘され、あらためて調査したところ、既存登録済13ページ・21箇所のグリッド定義がこのリファクタに追随できていないことが判明した。「Issue本文の完了基準(未登録画面の追加)だけを満たせば良い」という視野の狭さが原因であり、“陳腐化撲滅”というIssueの本質的な目的に立ち返らないと自力では検出できなかった - 検出方法: 該当リファクタのコミット(またはマージ済PR)のgit diff --statで変更ファイル一覧を取得し、.figma/ui-structure.json内で同名コンポーネントに対応するノードのgridTemplateColumns等の値を実ファイルの現在の記述と直接突き合わせる。ファイル一覧を先に確定させてから1件ずつ照合する方式が、闇雲にgrepで怪しいパターンを探すより網羅的かつ確実 - app/instagram/page.tsx・app/tiktok/page.tsxでは、グリッドパターンだけでなく構造自体も陳腐化していた: 以前は「アカウント一覧グリッド」と「アカウント管理エディタ」が別々のプレゼンテーション部品として別ノード登録されていたが、MorningStatusApp#1487でInstagramOfficialAccountsEditor/TikTokOfficialAccountsEditorコンポーネント自身がグリッド表示と編集UIを一体で担うよう統合されており、JSON側は統合前の構造のまま残っていた。グリッド値のパッチだけでなく、実際にpage.tsx側が何をレンダリングしているかをコンポーネント単位で読み直す必要があった - 対象外の判断: 同リファクタと無関係な既存のauto-fill/auto-fitグリッド(LiveMapPageの凡例グリッド、MemberDetailViewのProfileGrid等)は、対応する実ファイルを確認し実際に変更されていないことを確認した上で意図的に手を付けなかった。「陳腐化を直す」ことと「見つけた全てのパターンを機械的に統一する」ことは別であり、実コードとの突き合わせなしに書き換えるとかえって不正確な記録になる

  64. テスト用に環境変数を注入できる関数の引数をNodeJS.ProcessEnv型にしない(MorningStatusApp#1618) - Next.jsの型定義はProcessEnvのNODE_ENVを必須にしているため、テストで{ MEMBERS_BLOB_URL: '...' }のような部分的なオブジェクトを渡すと型エラーになる(Vitestは型を検査しないため、bun run type-checkで初めて発覚する) - 引数はRecord<string, string | undefined>にする。process.envもそのまま渡せる

  65. 設計書(batch-design.mdx等の運用リファレンス系も含む)は、対応する実装が完了する前でも目標の設計をそのまま記述してよい。「実装後は〜」「実装前は現行のまま」のような本文内の時制ヘッジは不要(morning-status-blume#166) - playlists.jsonのBlob→Neon移行(設計のみ先行、実装はMorningStatusApp#1680)でW3・W16バッチの設計書記述を更新した際、「まだ実装されていない内容を、現状の運用リファレンスに書いてよいか」で一度迷い、本文中に「この変更は実装完了後に有効になる」という注記を追加した - 指摘: 本プロジェクトの設計書は一貫して「設計が実装に先行する」方針(CLAUDE.md)であり、pending状態は改訂履歴の【対応する実装: 実装時に追記】マーカーだけで表現すれば十分。本文中に時制ヘッジを挟むと冗長で、かつ他の設計書(playlist-data-design.mdの§7等)との書き方の一貫性も崩れる - 対応: 本文は目標の設計をそのまま断定的に記述し、実装状況の追跡は改訂履歴の対応バージョン欄に一元化する。これは機能単位の設計書(playlist-data-design.md等)・横断的な運用リファレンス(batch-design.mdx・environment-variables.md等)のいずれにも同じ方針を適用する

このページは役に立ちましたか?