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

開発ノート

UI/UX設計

  1. UI Implementation (UI実装)

    • プレミアム感を出すために glassmorphism(グラスモーフィズム)を基調としたデザインを採用。
    • CSS Modules (*.module.css) を使用して、コンポーネント単位のスタイルをカプセル化。
    • Next.js App RoutergenerateStaticParams を活用し、メンバー詳細ページを静的に事前生成(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.cssprefers-color-scheme: dark を使い、bodycolor を明るい色に設定していた場合、入力系コンポーネント(<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: setBoundVariableForPaintopacity を保持しない

    • 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 操作との同期が必要な局所的な箇所にのみ使うこと。
    • 発生箇所: MemberDetailViewTourJumpSelect.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.tsxapp/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)

    • findActiveSubUnitlib/unit-helpers.ts)の「境界年は activeFrom が最大(最新)の期を採用」というタイブレーク規則(実装#25 参照)は、タンポポ・プッチモニ・ミニモニ(第1/2期)のように、親ユニットの活動期間を 隙間なく分割releaseIds を持たない「各期ユニット」同士の重複を想定した設計だった。
    • minihamuzubakatonosama-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 のみ指定してコンテンツに応じた可変高さにする。ブラウザの標準テキストレイアウトが単語境界を自動的に尊重するため、手動の文字数区切りロジックが不要になる。
    • 発生箇所: EventCardmedia/topic/festival タイプのプレースホルダー(画像未設定時のタイトル表示、components/EventCard.tsxTEXT_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.tsxMemberDetailView の既存パターンと同じ)。既存ページ全体が 'use client' になっている場合は、クライアントロジックを別ファイルの XxxView.tsx'use client')に切り出し、page.tsx を薄いServer Componentラッパーに戻すとよい(app/search/page.tsxSearchView と同型)。
    • 発生箇所: app/member-map/radar/page.tsx(レーダーチャート画面全体が 'use client' だったため、終端日設定機能の追加にあたり components/MemberMapRadarView.tsx に分離した)。

テスト手法

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

    • vi.useFakeTimers() を全面適用すると setTimeout も偽装され、@testing-library/reactwaitFor / 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 チェックを実装している場合、okundefined(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 キーワード: mockImplementationfunction キーワードを使った関数を渡す(インスタンスごとに返すオブジェクトを変えたい場合に有効):
      vi.mocked(Exa).mockImplementation(function() {
        return { search: mockSearch } as unknown as InstanceType<typeof Exa>;
      });
    • 方法2 — class 構文(Vitest v4 推奨): vi.mock のファクトリで実際の class を返すとコンストラクタとして正しく動作する。メソッドチェーンを伴うクラス(例: joseSignJWT)に特に有効:
      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 ヘルパーで mockSearchmockResolvedValueOnce を積み上げ、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/linkLink コンポーネントは 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 が即時実行される。afterEachvi.unstubAllGlobals() 済みなら追加のクリーンアップ不要。vi.useFakeTimers() + runAllTimersAsync より設定が少なく、既存の await fn() 呼び出しをそのまま維持できる。
  10. vi.clearAllMocks() はモック戻り値もクリアする

    • vi.mock('some-module', () => ({ fn: vi.fn().mockResolvedValue('default') })) のようにファクトリ内で初期戻り値を設定しても、beforeEachvi.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.tswebServer.commandbun run dev --turbo にして Turbopack を有効化すると動的コンパイルが大幅に速くなる。
    • タイムアウト緩和: playwright.config.tstimeout: 90000navigationTimeout: 45000expect.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回の呼び出し」を横取りする: readFileSyncunits.jsonmanual-releases.json の順で呼ばれる場合、mockImplementationOncemanual-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 --checkgit diff で不要差分がないことを確認する。
  21. lib/prisma.ts のモジュール初期化スローと間接インポートの罠(#856)

    • lib/prisma.tsDATABASE_URL 未設定のままモジュールがロードされると即座にエラーをスローする(export const prisma = globalThis.prismaClient ?? createPrismaClient() がトップレベルで実行されるため)。
    • この影響は「直接インポート」だけでなく「間接インポート」にも及ぶ。テスト対象スクリプトが lib/prisma.ts をインポートするモジュールを import していれば、テストファイル自体が lib/prisma.ts を踏む。
    • 症状: テストが DATABASE_URL 環境変数が設定されていません で fail する。スタックトレースを見るとスクリプト本体でなく初期化時点のエラー。
    • 対策: テストファイルの先頭(他の import より前)に必ずモックを宣言する:
      vi.mock('../lib/prisma', () => ({
        prisma: {
          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 }),
          },
        },
      }));
    • Vitest の vi.mock はホイスティングされるため、import 文の前に書いても問題ない。
    • 間接インポートを経由するケース(例: patch-tampopo-unit-id.tssync-discography.tslib/prisma.ts)でも同様に vi.mock('../lib/prisma', ...) が必要。
    • lib/releases.tslib/prisma.ts を使うため、getReleases を呼ぶスクリプトのテストでは vi.mock('../lib/releases', ...) も合わせて宣言すること。
  22. @types/react 19 では ReactElement.propsunknown 型になる(#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.mockimportOriginal が別のモック対象モジュールを 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 環境では playwrightchromium.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)

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

インフラ・設定

  1. スクリプトのTypeScript設定 (Scripts TypeScript Config)

    • scripts/ ディレクトリは tsconfig.jsonexclude に入っているため、@/ エイリアスは使用不可。相対パス(例: ../types/member)でインポートすること。
    • import.meta.main(Bun固有API)は TypeScript の ImportMeta 型に存在しないため、(import.meta as ImportMeta & { main?: boolean }).main でキャストする。
    • 将来のAPI実装のためのプレースホルダー引数(例: _query)には // eslint-disable-next-line @typescript-eslint/no-unused-vars を付与する。
  2. Vercel 本番環境でのファイル書き込み制限

    • Vercel のサーバーレス関数は /var/task(プロジェクトルート)が読み取り専用。fs.writeFileSyncEROFS: 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-nexteslint-plugin-react: '^7.37.0' を依存に持つため、eslint-config-nexteslint-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_URLprisma generate の実行に不要(スキーマ読み取りのみ)。スクリプトが DB 接続を必要とする場合にのみ env: に渡せばよい。
  8. server-only を Prisma ラッパー等のスクリプト共用ファイルに置いてはならない(#931)

    • lib/prisma.tsimport '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.tslib/tiktok-utils.ts)。クライアントコンポーネントは純粋関数のファイルのみをインポートすればよく、server-only は不要。
    • まとめ: server-only はスクリプト・ライブラリ層ではなく、Next.js のページ・レイアウト・Server Actions 等、Next.js バンドラーが必ず処理するファイルにのみ置く。
  9. ワークフロースクリプトのログレベル制御は lib/logger.ts で行う(#981)

    • scripts/ 配下の収集スクリプトは lib/logger.tslogger を使い、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.jsontypescript^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.tsxgetMembersFromBlob()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/revalidateAuthorization: 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): 重複コードを避けるため revalidateCachescripts/patch/revalidate-cache.ts に共通ユーティリティとして切り出し、patch-member-nicknames.tspatch-member-colors.ts をこれに追従させた上で、残る手動パッチスクリプト(patch-leader-generations.tspatch-instructor.tspatch-member-joining-routes.ts)にも同じ仕組みを導入した。これで scripts/patch/*.ts 配下の Blob 書き戻しを伴うスクリプトは全てキャッシュ破棄まで一貫して行うようになった。
    • キャッシュ戦略の統一(#1296): app/api/members/route.tsGETPUT(保存時の読み込み→書き戻し。書き込み競合を避けるため意図的に 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.tsblumeMdxProcessormermaidPlugin()を含む)は@astrojs/mdx用プロセッサとして.mdxにのみ適用され、.mdblumeMarkdownProcessor(mermaidPluginを含まない)が使われる。プレーンな.mdのまま ```mermaid ブロックを書いても、Mermaidの<blume-mermaid>要素へは変換されない
    • 対処: 図を含む設計書は.mdから.mdxgit mvでリネームする。リネーム後は他ファイルからの内部リンク([テキスト](旧ファイル名.md))も全て.mdxに更新する必要がある。リンク更新時は(?<![\w-])旧ファイル名\.mdのような否定後読み正規表現を使い、他の長いファイル名の部分文字列(例: screen-design.mdlive-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.tsmermaid.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プラグインが存在しない(grepresolveLink/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したもの(例: designDesign)がデフォルトの表示名になる。日本語の表示名にしたいカテゴリには必ず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/blobput()cacheControlMaxAge未指定時にデフォルトで1ヶ月CDNエッジキャッシュする仕様だった(公式型定義コメントで確認)。allowOverwrite: trueで同一URLを使い回す書き込みでは、Next.js側でrevalidatePathを呼んでもBlobの公開URL自体が持つCDNエッジキャッシュまでは制御できない。
    • 対処: 頻繁に書き換わるBlobへのput()呼び出しにcacheControlMaxAge: 60を追加する。#14のキャッシュバスティングはあくまで検証時の回避策であり、恒久対策にはならない点に注意。
    • リポジトリ全体でput()呼び出しを洗い出したところ34箇所全てで未指定だった。毎日スケジュール実行・ユーザー操作で頻繁に呼ばれる箇所(sync-status.tsdaily-digest.tscollect-instagram-posts.tsapp/api/members/route.ts、計5箇所)を本Issueで対応し、残り(手動実行専用ワークフロー・一回限りパッチスクリプト、29箇所)は#1443で追跡する。
  25. Electrobunデスクトップアプリに同梱されるBunバイナリは、システムにインストールしたBunとは独立した固定バージョン。上げるにはelectrobun.config.tsbuild.bunVersion指定が必要(#1494)

    • electrobunパッケージ(v1.18.1時点)はdist-win-x64/bun.exeとして自前で固定バージョンのBunバイナリnode_modules/electrobun/dist/api/shared/bun-version.tsBUN_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.tsbuildbunVersion: "1.3.14"のように明示指定する。指定するとビルド時にGitHub Releases(https://github.com/oven-sh/bun/releases/download/bun-v<version>/bun-windows-x64-baseline.zip等)から該当バージョンをダウンロードしてキャッシュし、同梱バイナリとして使用する(ensureBunBinarydownloadCustomBunnode_modules/electrobun/src/cli/index.ts参照)
    • 同梱バイナリの実バージョンは、ビルド後に desktop/build/<target>/<AppName>/bin/bun.exe --version で確認できる

実装

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

    • 全歴代メンバー(47名)の情報を data/members.json に集約。
    • id はURLセーフなローマ字表記(例: nakazawa-yuko)を使用。
    • statusActive(現役)または 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.tstest.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:00Z14:59:59Z の範囲(JST 09:00:0023:59:59)のタイムスタンプを使うこと。
  5. Tavily include_domains による公式SNS優先検索

    • performSearchincludeDomains?: 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 のドライランと副作用の制御

    • performSearchSEARCH_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.SyntheticEvente.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.jpx.com のような共有ドメインでは、hostname だけで公式SNS判定すると他アカウントの投稿を誤って「公式更新」と判定してしまう。
    • 対策: hostname に加えて「アカウント識別子(通常はパス先頭セグメント)」まで一致させる。 例: https://ameblo.jp/member-a/...member-amember-b を区別する。
    • twitter.comx.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/catchYouTubeQuotaExceededError を受け取り 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.configagentRules: false でオプトアウトも可能)。
  21. スクリプト内ローカル型に [key: string]: unknown がある場合の型キャスト回避(#472)

    • スクリプト固有の型(例: build-playlist-index.tsRelease)は 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.tsReleaseFormattypes/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:/...(先頭スラッシュ付き)を返し、writeFileSyncENOENT でクラッシュする。
    • 修正: fileURLToPath(new URL('./foo.json', import.meta.url)) を使うと OS ネイティブなパス(D:\...)が得られる。
    • テスト対応: Vitest の jsdom 環境では import.meta.urlfile: スキームでないため fileURLToPathERR_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期)”)の照合は「括弧前のベース名でグループ化 → リリース年が activeFromactiveTo に含まれる期を選択」で行う。境界年(例: activeTo=2002 と activeFrom=2002 が重なる)では activeFrom が最大の期(最新期)を採用する。
    • これにより、既存の Track.unitId が設定済みのトラックは再処理で上書きされない(手動修正保護)。
  26. Prisma 7 の設定構造と Neon アダプターの使い方(#609)

    • Prisma 7 では schema.prismadatasource ブロックに url = env("...") を書けなくなった。接続 URL は prisma.config.tsdatasource.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.tsmigrate.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_URLundefined になり、prisma.config.ts の接続 URL が空文字になって以下のエラーが発生する:
      Error: Connection url is empty. See https://pris.ly/d/config-url
    • 対策: prisma.config.tsdotenv を使って .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.tsprocess.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();
      });
    • beforeEachasync にして共通セットアップで put を取得し、各テストでも await import で取得すれば vi.mocked(put) が正しく機能する。
    • vi.clearAllMocks() は呼び出し履歴だけでなくモックの返り値もクリアするため、beforeEachmockResolvedValue をセットし直すこと。
  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.tsdesktop/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 用 .iconsetsharp(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 --stdinUPDATE "_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.tsremotePatterns にすべてのドメインを列挙するのは現実的でない。
    • <Image unoptimized> を使うと Next.js の画像最適化プロキシをバイパスし、URL をそのまま <img src> に渡せる。CORP ヘッダーによるブロックは発生しない(最適化プロキシ経由でないため)。
    • referrerPolicy="no-referrer" も合わせて指定し、リファラーによるアクセス制限を回避すること。
    • 画像最適化(WebP変換・リサイズ)は行われないため、パフォーマンス上のトレードオフがある。固定ドメインが確定しているサービス(Instagram・YouTube 等)は引き続き remotePatterns を使うこと。
  48. "use client" ファイルへの import 追加時はサーバー専用モジュールの混入を確認すること(#913)

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

    • Prisma クエリ(Neon)は unstable_cachenext/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.tsvi.mock('next/cache', () => ({ unstable_cache: (fn) => fn })) を追加して回避する。
      • Bun スクリプト: unstable_cache でラップされた関数(例: getReleases)を scripts/ から呼ぶと毎回エラーになり、try/catch のフォールバックで空データを返す。スクリプトからは unstable_cache を使わない 生の関数(例: fetchReleasesFromDB)を別途 export して使うこと。
    • force-dynamic ページでも unstable_cache / next: { revalidate } のデータキャッシュは独立して機能するため、ページ全体の動的レンダリングとデータのキャッシュは排他ではない。
  50. Instagram サーバーサイド CDN チェックは廃止・クライアント側 onError に移行(#875)

    • Instagram CDN はボット判定で並列リクエストをブロックしやすい。filterInaccessiblePosts で直列にサムネイル URL を検証していた(#951)。
    • #875 で方針転換: サーバーサイドでの Instagram CDN アクセスチェックは信頼性が低く SSR パフォーマンスの妨げになるため、Next.js アプリ側では行わない。filterInaccessiblePostslib/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/cacherevalidateTag のシグネチャが 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_cachetags: ['releases'] オプションを付与した上でいずれかを呼ぶことで、キャッシュを即時無効化できる。
    • テストでは vi.mock('next/cache', () => ({ revalidateTag: vi.fn(), updateTag: vi.fn() })) でモックする。
  55. vitest の TZ: 'Asia/Tokyo' 設定が UTC 本番環境のタイムゾーンバグを隠す(#1041)

    • vitest.config.tsenv: { 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 生成パターン」が混在している場合は特に注意が必要。例: nearestSaturdaysetHours(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.upsertupdate 句に releaseId を含めないと、コンピレーション先行登録トラックがシングルに移行しない(#1061)

    • saveDiscographyToNeontrackMap は format 優先度(single=1 > album=2 > compilation=4)でトラックの帰属リリースを決定するが、prisma.track.upsertupdate 句に releaseId を含めていなかったため、コンピレーション等で先に登録されたトラックが UPDATE パスで releaseId を更新されず、シングル配下に移行しなかった。
    • 結果として REFETCH_ALL_SINGLES=1 を実行してもシングルに紐づくべきトラックが tracks テーブルのシングル releaseId で見つからない状態になっていた。
    • 対策: update 句に releaseId を追加し、format 優先度で選ばれた勝者リリースに releaseId を移動させる。
  58. Prisma updatedataundefined を渡すとそのフィールドは更新されない(#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.jsonexclude に入っているため、bun run type-check は import 漏れ(未定義識別子の参照)を検出しない。
  • Bun 実行時には ReferenceError がスローされるが、該当コードが try/catch 内にあるとエラーが握りつぶされ、フォールバック値(空配列等)が返り続ける無音の機能不全になる。
  • 実例: collect-instagram-posts.tsfetchExistingDailyPostsDailyNewPostsSchema を 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 - groupXrelY = 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.tsgetCommonRelationshipTypes(ペアキーを [a, b].sort().join('--') で正規化し、種別ごとに Set へ積み上げて必要数と比較)を参照。
  1. 並列エッジのオフセットは始点・終点ではなく中間の制御点だけをずらす(#1272)
  • 同一ノードペア間に複数のエッジがある場合に重なりを防ぐため横にずらす実装で、始点(sourceX/Y)と終点(targetX/Y)の両方に同じベクトルを加算すると、線全体がノードのハンドル位置から平行移動してしまい、始点・終点どちらもノードに接続していないように見える(「宙に浮いた」エッジになる)。
  • 同一ペア間に関係種別が1つしかない間は目立たないが、同一ペアに複数の関係種別(同期・同郷・同在籍等)が存在する場合にのみ発生するため、テストデータや目視確認で見落としやすい。加えて、他のエッジの陰に隠れて気づかれにくいこともある(#1265 でノイズとなる関係を非表示にした結果、この不具合を持つエッジだけが単独で目立つようになり発覚した)。
  • 正しい対処: 始点・終点は実際のハンドル位置に固定したまま、2次ベジェ曲線の制御点(始点と終点の中点 + 垂直オフセット)だけをずらす。M source Q (中点+オフセット) target の形にすることで、両端は必ずノードに接続されたまま曲線だけが横に膨らむ。
  • 実装は lib/member-map-utils.tsgetParallelEdgeControlPointcomponents/GradientEdge.tsx を参照。オフセットが 0 の場合(並列エッジがない、またはインデックスが中央)は従来通り getBezierPath を使う。
  1. 共演エッジの label 表示は集計側(DB)とフロント側の2箇所を両方更新しないと反映されない(#1263)
  • scripts/workflow/sync-relationships.ts の集計クエリで member_relationships.label に値を格納しても、lib/member-map-utils.tsgetEdgeLabel() が参照する COAPPEARANCE_NAME_TYPESSet)にその type を追加していない場合、画面には反映されず常に RELATIONSHIP_LABEL_MAP の固定文言が表示され続ける。
  • radio-coappearance/event-coappearance は既に COAPPEARANCE_NAME_TYPES に含まれていたが、live-coappearance は含まれておらず、DB側だけ label を算出する変更をしても無効化されたままになるところだった。新しい共演系エッジ種別を追加・変更する際は、集計クエリ(バックエンド)と getEdgeLabel の対象セット(フロントエンド)の両方を確認すること。
  1. member_relationships.label を type ごとに意味転用する際は getMemberGroupKey のグルーピングキーへの影響も確認する(#1269)
  • labelsame-unit(ユニット名)・共演系(最多共演名)など type ごとに異なる意味で再利用されてきたが、mentor-student に direct/indirect の区別を持たせる際、getMemberGroupKeylib/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)
  • GradientEdgecomponents/GradientEdge.tsx)は sourceColortargetColorlinearGradient でエッジを描画する。関係性スコアなど「メンバーカラーとは無関係な指標」を色で表現したい場合、SVG 構造(<defs><linearGradient>)はそのまま流用し、sourceColortargetColor に同じ値(スコアに応じた単色)を渡すだけで見た目上は単色エッジになる。エッジコンポーネントの分岐を増やさずに済む。
  • 太さも同様に、既存の getEdgeStrokeWidth(weight) はスコア(0〜100pt)にもそのまま適用できる(weighttotalScore は共に「大きいほど太くする」正の数値という同じ意味役割のため、変換式を共有できる)。
  • 表示モードの切り替えは既存の filter/group モードの state 分岐に混ぜ込まない: member-map/page.tsxmode(filter/group)×axis の組み合わせで nodes/edges state を書き換える複雑な useEffect を持つが、スコアリングモードは「現在の edges に対して太さ・色だけを上書きする」独立した useMemodisplayEdges)として実装し、<ReactFlow edges={displayEdges}> に渡す。既存モードのどの分岐が edges を作っても後段で一律に上書きできるため、モード分岐を1つも触らずに新しい表示軸を追加できた。
  • スコア計算関数(getRelationshipScore)は「メンバー2人+関係性リスト+都道府県コード」を受け取る純粋関数として実装済み(#1269)だったため、事前計算してDBに保存する batch/sync は不要で、画面表示時にその場で計算するだけで済んだ。バッチで事前計算するかその場で計算するかは、値が「表示都度変わりうるか」(例: 在籍重なりは now に依存し日々変わる)で判断するとよい。
  1. ControlAxislib/member-map-utils.ts)に新しい軸を追加する際に触れるべき関数一覧(#1266)
  • 軸を1つ追加すると、以下すべてに分岐を足す必要がある。1つでも漏らすと「軸ボタンは出るが選択肢が空」「フィルタは効くがグルーピングだけ効かない」といった中途半端な状態になる。
    • getAxisLabel(軸ボタンの日本語ラベル)
    • getAxisValueLabel(値セレクタの表示ラベル)
    • getAxisValues(値セレクタの選択肢一覧)
    • getFilteredMemberIds(フィルタモードのハイライト対象算出)
    • getMemberGroupKey(グルーピングモードのグループキー算出)
    • buildGroupInfos(グループラベル・ソート順)
    • MapControlPanelAXES 配列(軸ボタンの表示自体)
  • 一方 getFilteredEdgeIdsaxis === '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.tspatch-instructor.ts 等)。
  • リーダーの愛称機能を実装した際、当初 lib/member-map-utils.ts から data/leader-nicknames.json を直接 importresolveJsonModule 利用)する実装にしてしまった。これは静的importのためNext.jsのビルド時にバンドルされ、members.json(Blob経由でランタイム取得)と違ってJSONを編集しただけでは本番に反映されず、コミット+デプロイが必要になる。既存の「コンテンツはBlob、コードはデプロイ」という設計原則に反していた。
  • 対処: types/member.tsMember スキーマに leaderNickname: z.string().optional() を追加し、scripts/patch/patch-leader-nicknames.tspatch-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.tsbuildMemberRelationshipRanking を参照。
  • タイの並び順は getScoreColor 等と同様、値が同じ場合のタイブレークキーを明示することで再実行しても順序が変わらないようにした(既存の同数タイブレーク方針と同じ考え方)。当初は memberId の文字列順だったが、意味のある表示順ではなかったため #1291 でグループ優先順位・加入日/卒業日ベースの基準に置き換えた(76番を参照)。
  • TypeScriptの余剰プロパティチェックの回避パターン: RelationshipScoretotal 持ち)と MemberGroupRadarScorepairCount 持ち)のように、共通の4軸フィールドだけを受け取る型(RadarAxisScores)を関数の引数型にすると、両方の型を構造的に受け入れられて実装の重複を避けられる。ただしテストでオブジェクトリテラルを直接渡すと「余剰プロパティチェック」に引っかかり型エラーになるため、一度変数に代入してから渡すことでチェックを回避する(lib/member-map-utils.test.tstoNormalizedRadarValues テストを参照)。
  1. getRelationshipScore が絡む「総合スコアは同値だが日付が異なる」テストケースは OVERLAP_MONTHS_CAP(10年)を超える差にして overlap 軸を両方 40pt に飽和させる(#1291)
  • buildMemberRelationshipRanking のタイブレーク順(グループ優先度→joinDate/gradDate)をテストする際、素朴に joinDate/gradDate だけを変えると getOverlapScore(在籍期間の重複月数ベース)まで変化し、総合スコアがタイにならず意図したテストにならない。
  • 対策: 基準メンバーとの在籍重複期間が OVERLAP_MONTHS_CAPlib/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_WEEKDAYSscripts/lib/og-sync.tsshouldSyncOG())による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.tspageCount === 0 判定・scripts/workflow/collect-onair-morning.ts のカーソル done フラグには、「取得上限到達」であって「履歴完了」ではない旨をコメント・ログメッセージで明示すること。
  1. String[] フィールドが未登録(空配列)のレコードを hasSome で絞り込むと除外される問題は、書き込みパイプラインより先に読み取り側フォールバックを検討する(#1326)
  • events.membersString[])に memberLives 未登録のライブが members: [] で登録されており、hasSome: selectedMemberIds によるメンバー絞り込みクエリではこれらが常に除外され、「新規登録した公演が年表に一切表示されない」バグになっていた。
  • 当初は member_lives へ在籍期間ベースで自動書き込みするパイプラインの新設(要設計)を検討したが、events レコード自体は既に存在していたため、読み取り側のクエリ条件を緩めるだけで解消できることが判明し、書き込み側の設計は不要になった。
  • パターン: Prisma の whereOR: [{ members: { hasSome: selectedMemberIds } }, { type: "live", members: { isEmpty: true } }, ...] を指定していったん該当レコードを取得し、members が空だった行だけをアプリ側でフォールバック判定する(lib/timeline-utils.tsresolveEventMembers/withResolvedMembers)。isEmpty フォールバックを type で絞らないと、無関係な種別まで巻き込んでしまう点に注意。
  • 表示用データも同時に補完する: フィルタ判定だけでなく members 配列自体を導出結果に差し替えることで、EventCard のアバター表示・地図リンク表示(event.members.length > 0 条件)も正しく機能するようになる。単に絞り込み条件だけ緩めると、イベントは表示されてもメンバー情報が空のカードになってしまう。
  • 種別ごとに最適な導出ロジックが異なる点に注意(当初 live のみで実装し、レビューで festival・release も同根の問題と指摘されて拡張した):
    • livefestival: 個別の出演者データを持たない団体イベントとして扱い、開催日時点の在籍期間(joinDategradDateisMemberActiveOnDate)で判定する。
    • release(モーニング娘。本体): 単純な在籍期間ではなく、既存の lib/member-release-linker.tslinkMemberToReleases)が持つ「加入翌月以降で最初のシングル発売日」を実質基準日とするロジックを再利用する。加入直後の制作済みリリースへの誤紐づけを避けるための既存ロジックがあるのに、簡易な在籍期間判定で代替すると精度が落ちる。
    • release(ユニットによるリリース): 在籍期間ではなく固定メンバー(unit.memberIds)で判定する(lib/unit-helpers.tsgetSubunitMembersByReleaseId)。ユニットは全体の在籍期間と無関係な別ロジックのため、同じ関数に混ぜず種別で分岐する。
  • 教訓: 「表示条件を緩める」対応をする際、対象を最初に気づいた種別(今回は live)だけに限定しがちだが、同じ empty-array 設計(=「個別登録の手間を省くための空配列」)を持つ他の種別がないか横展開の要否を確認すること。判定ロジックの精度が種別ごとに異なることは、対応範囲を狭める理由にはならない。
  1. 「ループ内はチェックあり・ループ後の単発処理はチェックなし」という非対称な重複排除ガードは見落とされやすい(#1329)
  • sync-status.tssyncMembers() は、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.tsloadUnits() は元々 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.tsxliveGroupWrapper)に 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 の挙動がブラウザ実装依存になる。
  • 対策: EventCardid prop はオプションにし、LiveTourGroup 内部で呼び出す箇所には渡さない(ラッパー側の id だけをスクロール先アンカーにする)。単体(kind: "single")で直接描画する EventCard にのみ id={item.event.id} を渡す。
  • 一般化: あるコンポーネントが内部で子コンポーネントを複数回・別文脈で再利用している場合、その子コンポーネントに一律で id を追加するのは危険。「外側のラッパーに付ける」か「本当に一意な描画箇所にだけ渡す」かを、再利用パターンを先に確認してから決めること。
  1. scripts/patch/*.tsrevalidateCache 呼び出しは 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.tsxapp/songs/[id]/page.tsxapp/timeline/page.tsx)は全て export const dynamic = 'force-dynamic' でリクエストごとに Prisma を直接叩いており、Next.js のキャッシュを経由しない。そのため DB を直接更新するパッチスクリプトに revalidateCache を追加しても意味がなく、既存の collect-onair-morning.tsregister-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.jsondate に日時を1文字列で詰め込むと、parseDateString の正規表現に一致せず該当イベントがログにしか残らず静かに消える(#1381)

    • sync-events.tsparseDateStringYYYY-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 カウントに含まれず、タイムラインに一切表示されないまま気づかれにくい状態が続いていた。
    • 対策: #1381Event.timeDateTime? @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.jsondata/inputs/manual-radio-episodes.json への移動(#1337「Dataディレクトリの整理」コミット)で、同様の入力ファイルを持つ他5スクリプト(sync-discography.tsseed-member-relationships.tspatch-member-nicknames.tspatch-member-colors.tspatch-member-joining-routes.ts)は全て参照パスが data/inputs/... に追随して更新されていたが、register-manual-radio-episodes.tsMANUAL_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 が含まれるかで判定する(extractPostIdfindIndex と同じ考え方に統一)。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_ACCOUNTSinstagramUrlを逆引き」する設計に変更し、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.jsonmoduleResolution: "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.maintsx ローダー経由では 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・ネイティブnodenode --import tsx の3パターン全てで正しく動作することを確認済み。
    • 教訓: 「node で動く」という検証は、実行方法(プレーンnode / ローダー経由)・環境変数読み込み・エントリポイント判定という複数のレイヤーそれぞれで成立を確認しないと、一部レイヤーだけ検証して「動作する」と一般化してしまう。特にBun向けに書かれたコード(import.meta.main 等のBun-optimized API)をNodeで動かす場合は、そのAPIがローダー経由でも同じ挙動を保証するかまで確認すること。
  8. 同じ対象データに対する判定ロジックが複数箇所に分散していないか、新規実装前に確認する(#1403)

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

    • Ameba(components/NewPostsSection.tsxのAmebaウィンドウ判定)・TikTok(同ファイルのrecentTikTokDate)・YouTube(lib/youtube.tsgetRecentYoutubePosts)の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/*.tsrevalidateCache 呼び出し漏れ(実装#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 パターン)を追加し、officialog 両方の収集スクリプトが実行完了時に同じ3カラムを共有スライドさせる、scripts/workflow/sync-status.ts の Ameba 向け実装と完全に同一のロジックにした。
    • この初版が再発させた別の不具合: Ameba は現役・OGを1つのワークフロー内でまとめて処理するため単一ウィンドウの共有スライドで問題ないが、YouTubeはofficial(毎日)・ogOG_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.jsonprisma/@prisma/client^7.9.0 に更新済みでも、bun install を実行せず bunx prisma generate を叩くと、node_modules に残っていた旧バージョン(このケースでは 7.8.0)でコマンドが実行され、bunx は警告なしにそのまま完走する。「さらに新しいバージョンが存在する」ケースでは末尾に Update available の案内が出るが、「lockfile が期待するバージョンと乖離している」ケースでは何も警告されない。
    • 教訓: Prisma スキーマを変更してマイグレーションを作成する前は、bun install を必ず実行してから bunx prisma generatebunx prisma migrate dev --name <name> の順で進めること(実装#42の手順の前提条件として、bun install 省略不可を明示する)。git pull 直後の実装開始チェックリストと同じ理由で、依存関係が古いままだと不整合が症状化するまで気づけない。
  13. OfficialSummaryEntry 移行はSNS別収集スクリプト(#1455/#1456)が集約側(send-daily-digest.ts、#1457)より先にマージされる順序で分割されており、単体マージ時は集約側が一時的にクラッシュしうる(#1455)

    • send-daily-digest.tsbuildDigestEmailBodyentry.source === 'youtube-official' のみを特別扱いし、それ以外の source は無条件で MemberSummaryEntry にキャストして entries を反復する。instagram-official/tiktok-official 等の新しい OfficialSummaryEntry ソースが書き出されると、entries が存在せず(OfficialSummaryEntryaccounts を持つ)TypeError: memberEntry.entries is not iterablesend-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.tsnormalizeForNameMatch()﨑→崎髙→高栁→柳𠮷→吉 の4種の異体字正規化のみを持ち、櫻→桜 が未対応だった。 - 調査手法: Blobから取得した members.json の対象メンバーの name フィールドと、Neonに保存された投稿の実際の caption を突き合わせて文字単位で比較し、一致しない箇所を特定した(メンバー名は目視では新字体・旧字体の区別がつきにくいため、文字列比較で機械的に検出する必要がある)。 - 同一バグの横展開: normalizeForNameMatch()lib/tiktok-attribution.tslib/youtube-attribution.tslib/instagram-attribution.ts の3ファイルに同一実装が重複しており、同じ原因のバグが3ファイルすべてに存在していた。共通モジュールへの切り出しはされていないため、1ファイルだけ直して他を放置すると同じ不具合が他のSNSでも再現する。 - 永続化の有無で対応が異なる: TikTok(lib/tiktok.tsgetTikTokPosts 経由、mentionedMemberIds をNeonに永続化)は収集済みの過去データを遡及修正するワンショットパッチスクリプトが必要だが、YouTube/Instagramは app/members/[id]/page.tsx でリクエスト時に attributeYoutubeVideoToMembers/attributeInstagramPostToMembers を計算しており非永続化のため、コード修正のみで即座に反映されバックフィル不要だった。 - 教訓: 複数のSNS収集ロジックが同じパターン(ハッシュタグ抽出→メンバー名正規化→マッチング)で実装されているコードベースでは、1箇所で見つかった正規化漏れ・表記ゆれバグは、類似実装が他に重複していないか横展開調査を必ず行うこと(grep で関数名・処理パターンを検索)。また、修正対象データがBlob経由(非永続・キャッシュのみ)かNeon直接永続化かによって、バックフィルの要否が変わる点も合わせて確認すること。

  15. Amebaグループブログの投稿者判定は「タイトル末尾に最も近いメンバー名」で一意に絞り込む(#1480) - グループ共有ブログでは findMemberAmebaItem/findAllNewMemberAmebaItemsscripts/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の使用)を提示している。

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