開発ノート
UI/UX設計
-
UI Implementation (UI実装)
- プレミアム感を出すために
glassmorphism(グラスモーフィズム)を基調としたデザインを採用。 CSS Modules(*.module.css) を使用して、コンポーネント単位のスタイルをカプセル化。Next.js App RouterのgenerateStaticParamsを活用し、メンバー詳細ページを静的に事前生成(SSG)することで高速な遷移を実現。- セマンティックHTML(
<ruby>,<main>,<header>等)を適切に使用し、アクセシビリティと構造化を意識。 - Server Component から Client Component への props 渡し:
page.tsx(Server Component)はデータ取得のみ担当し、インタラクティブな処理は'use client'の子コンポーネントに委譲する構成が望ましい。memberオブジェクトのような JSON シリアライズ可能なデータは props として渡せる。
- プレミアム感を出すために
-
SNS表示は二重バッジではなく単一状態に統一(#224)
- 「正常性」と「更新有無」は別軸だが、UIは単一バッジ(排他)にした方が運用時の読み取りミスが少ない。
- 推奨状態:
ok + hasRecentUpdate=true→ 更新あり(緑)ok + hasRecentUpdate=false→ 更新なし(灰)error→ 取得エラー(赤)skipped/unknown→ 未確認(黄)
- 上記状態は
member.snsCheckに保存し、フロントはsnsCheckをそのまま表示する構造にするとロジックが単純になる。
-
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 キャッシュの無効化(次回アクセス時に最新データを取得)のためにはなお必要。
- Next.js App Router では
-
prefers-color-schemeによる CSS カスケードが入力コンポーネントに波及する問題globals.cssでprefers-color-scheme: darkを使い、bodyのcolorを明るい色に設定していた場合、入力系コンポーネント(<input>,<textarea>,<select>等)にbg-*とtext-*を明示指定していないと、ダークモードで背景が白のままテキスト色だけが白くなり文字が見えなくなる。- 対策: 全入力系コンポーネントに
bg-white text-gray-900(またはそれに相当するスタイル)を明示指定する。1つのコンポーネントで問題を発見した場合、同じパターンを持つ他コンポーネントも確認すること。 bodyレベルの色指定は全子要素に波及するため、入力要素はブラウザ・OS設定に依存せず明示的な背景色・テキスト色を持つのが原則。
-
詳細ページの前後ナビゲーション順は一覧の並び順と一致させ、同値時のタイブレークも明示する
- 詳細ページに「前へ / 次へ」を追加する場合、ユーザーが一覧画面で見ていた順序と一致していないと移動体験が不自然になる。
- 対策: 前後ナビのソート条件は一覧画面と同じ基準にそろえること。さらに、日付などの主キーが同値になる場合に備えて、タイトルや名称によるタイブレークを実装とテストの両方で固定する。
- 例:
- リリース詳細: 現在の
formatと同じリリースだけを対象にし、releaseDate降順、同日ならtitle昇順 - プレイリスト詳細:
publishedAt降順、同日ならname昇順
- リリース詳細: 現在の
- 先頭・末尾・中間の3パターンに加え、同値タイブレークのテストも用意しておくと回帰を防ぎやすい(#462)。
-
Figma Plugin API:
setBoundVariableForPaintはopacityを保持しないfigma.variables.setBoundVariableForPaint()にペイントオブジェクトを渡す際、opacityフィールドを指定しても 保持されず 1.0(不透明)になる。- 半透明の背景(ボタン・バッジ・ナビゲーション pill 等)には変数バインドを使わず、直接 fill を指定すること:
// NG: opacity が無視されて不透明な白になる node.fills = [figma.variables.setBoundVariableForPaint( { type: 'SOLID', color: { r: 1, g: 1, b: 1 }, opacity: 0.08 }, 'color', borderVar )]; // OK: opacity を直接指定する node.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 }, opacity: 0.08, blendMode: 'NORMAL' }]; - 白背景に白テキストという最悪の見た目になるため気づきにくい。
use_figmaで半透明 fill を含むノードを作成したら、必ずget_screenshotで即確認すること。 - 発生箇所: メンバー一覧・ユニット一覧のナビゲーション pill(2026-03-31 ユーザー手動修正)。
-
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'修正で対処)。
-
state 更新後に DOM 操作が必要な場合は
flushSyncを使う(#669)- React の
setStateはデフォルトで非同期(次の render で反映)。そのためsetState直後にscrollIntoViewなどの DOM 操作を行うと、更新前の DOM に対して操作が実行されてしまう。 flushSyncで state 更新を同期的に完了させてから DOM 操作を行うことで、更新済みの DOM に対して確実に操作できる:import { flushSync } from 'react-dom'; // NG: setExpandedTours が非同期のため、scrollIntoView 実行時にまだ折りたたまれている onSelect={(targetId) => { setExpandedTours((prev) => new Set([...prev, key])); // ↑ 次の render まで DOM に反映されない }} // OK: flushSync で展開を確定させてから scrollIntoView が実行される onSelect={(targetId) => { flushSync(() => { setExpandedTours((prev) => new Set([...prev, key])); }); // ↑ flushSync 内の更新は同期的に DOM へ反映される }}flushSyncはパフォーマンスへの影響があるため、DOM 操作との同期が必要な局所的な箇所にのみ使うこと。- 発生箇所:
MemberDetailViewのTourJumpSelect.onSelect(#669)。
- React の
-
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);
- 親 page.tsx に「必ずすぐ表示したいデータ」だけを残し、「遅くても構わないデータ」は async サーバーコンポーネントに切り出して
-
画面設計書を実装と突き合わせる際は「戻る」リンクの文言・遷移先を必ず実コードで確認する(#1366)
- 「トップに戻る」に相当するリンクは共通コンポーネント化されておらず、各
page.tsxに個別実装されている。そのため文言がホームに戻る/メンバー一覧に戻るのようにページごとに食い違っていることがある(例:app/units/page.tsx・app/lives/page.tsxは「ホームに戻る」だがapp/comparisons/page.tsxは「メンバー一覧に戻る」のまま)。設計書に書く際は必ず対象ページの実際のaria-label/ リンクテキストをgrepしてから記載すること。他ページの表記をそのまま流用しない。 - 同様に、一覧画面→詳細画面の「戻る」の遷移先も、実装によっては一覧そのものではなく別の中間ページやトップに飛ぶ非対称な設計になっていることがある(例:
app/setlists/[id]/page.tsxはライブ経由で入った場合/lives/[id](詳細)ではなく/lives(一覧)に戻る)。往路と復路を別々に確認すること。
- 「トップに戻る」に相当するリンクは共通コンポーネント化されておらず、各
-
一覧画面への常設導線を「データ0件なら非表示」の条件レンダリングに乗せない(#1404)
NewPostsSectionの Instagram/TikTok/YouTube/Ameba 各セクションは、当該SNSの新着投稿が1件以上ある場合のみセクション(および/instagram等一覧画面への唯一のリンク)を描画していた。そのため新着が0件の日は、その画面へトップページから到達する手段が完全になくなっていた。- 対策: セクションの見出し・一覧画面へのリンクは常に描画し、投稿が0件のときは中身だけを「本日の新着投稿はありません」のようなプレースホルダーに差し替える。可視性そのものをデータ件数に結びつけない。
- 見落としやすい点: 個別セクションを常時表示に直しても、複数セクションの「いずれか1件でもあるか」を判定する集約フラグ(例:
hasAnyUpdates)で親コンポーネント自体を丸ごとreturn nullにしていると、全セクションが同時に0件の日にまったく同じ問題が再発する。個別の条件分岐だけでなく、その上位にある集約ガードの要否も合わせて確認すること。
-
releaseIdsを持たない「各期ユニット」に、releaseIdsを持つ「企画ユニット」を活動期間が重複する形で追加すると、境界年のタイブレークで表示が誤解決される(#1426)findActiveSubUnit(lib/unit-helpers.ts)の「境界年はactiveFromが最大(最新)の期を採用」というタイブレーク規則(実装#25 参照)は、タンポポ・プッチモニ・ミニモニ(第1/2期)のように、親ユニットの活動期間を 隙間なく分割 しreleaseIdsを持たない「各期ユニット」同士の重複を想定した設計だった。minihamuzu・bakatonosama-minimonihime(#1419)のように、特定楽曲のみに紐づくreleaseIdsあり「企画ユニット」を、既存の各期ユニット(minimoni-1等)と活動期間が重複する形で追加すると、企画ユニット側がタイブレークで優先され、企画ユニットに紐づかない本来の各期ユニットの楽曲まで誤って企画ユニット名で表示されてしまう。- 対策:
findActiveSubUnitに対象リリースのreleaseIdを渡し、候補ユニットがreleaseIdsを持つ場合は自身のreleaseIdsに対象リリースが含まれることを追加条件にした。releaseIdsを持たない各期ユニットは従来通り期間のみで判定する。 - 今後の注意点: 親ユニットに新しい企画ユニット(
parentIdあり・releaseIdsあり・特定楽曲のみ対象)を追加する際は、既存の各期ユニットと活動期間が重複しないか確認すること。重複していても本対策により誤表示は起きないが、念のため活動期間の設計時に意識するとよい。
-
SVG
<text>は自然な折り返しができない。省略なく全文表示したい可変長テキストは HTML/CSS ブロックに置き換える(#1472)- SVG の
<text>要素は CSS のwhite-space/overflow-wrapによるテキスト折り返しに対応しておらず、複数行にするには<text>を複数並べる手動実装が必要になる。文字数ベースで機械的に改行位置を決めると、英数字タイトルで単語の途中(例:"MUSIC"→"MUS"/"IC")で折れてしまう。 - 行数を固定(例: 2行)し、溢れた分を
…で省略する方式は、表示するテキストの長さが可変な場合に必ず一部のケースで全文を表示できなくなる。「全文を省略なく表示する」という要件とは根本的に両立しない。 - 対策: 全文表示が要件の場合は SVG での手動折り返しをやめ、固定サイズの
viewBoxの代わりに<div>等の HTML ブロック(overflow-wrap: break-word)で描画し、コンテナはaspect-ratio固定をやめてmin-heightのみ指定してコンテンツに応じた可変高さにする。ブラウザの標準テキストレイアウトが単語境界を自動的に尊重するため、手動の文字数区切りロジックが不要になる。 - 発生箇所:
EventCardのmedia/topic/festivalタイプのプレースホルダー(画像未設定時のタイトル表示、components/EventCard.tsxのTEXT_TITLE_TYPES)。
- SVG の
-
'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'を評価した結果をisDesktopModepropとして子のClient Componentへ渡す(app/members/[id]/page.tsx→MemberDetailViewの既存パターンと同じ)。既存ページ全体が'use client'になっている場合は、クライアントロジックを別ファイルのXxxView.tsx('use client')に切り出し、page.tsxを薄いServer Componentラッパーに戻すとよい(app/search/page.tsx→SearchViewと同型)。 - 発生箇所:
app/member-map/radar/page.tsx(レーダーチャート画面全体が'use client'だったため、終端日設定機能の追加にあたりcomponents/MemberMapRadarView.tsxに分離した)。
テスト手法
-
Vitest の Date モックと waitFor の共存
vi.useFakeTimers()を全面適用するとsetTimeoutも偽装され、@testing-library/reactのwaitFor/findBy*が動作しなくなる場合がある。Dateのみを偽装したい場合はvi.useFakeTimers({ toFake: ['Date'] })を使うとsetTimeoutは本物のまま保たれ、waitForとの競合を回避できる。afterEach(() => vi.useRealTimers())でリセットを忘れずに行うこと。
-
Next.js API Route で global fetch をモックする(Vitest)
- Vercel Blob URL や外部エンドポイントへの
fetchを使う API ルートをテストする際は、vi.stubGlobal('fetch', vi.fn().mockResolvedValue(...))でグローバルのfetchをモックする。 vi.mockではなくvi.stubGlobalを使う理由:fetchはモジュールではなくグローバルに存在するため。- 正常系ではレスポンスの
jsonメソッドに加えてok: trueもモックに含めること。res.okチェックを実装している場合、okがundefined(falsy)になると正常系テストが全て失敗する:vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(mockData), }), ); - エラー系テストでは
ok: false, status, statusTextを指定:vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', }), ); vi.stubEnv('MEMBERS_BLOB_URL', url)と組み合わせることで、環境変数と fetch 両方をbeforeEachでリセット可能。
- Vercel Blob URL や外部エンドポイントへの
-
モジュールレベル const と vi.stubEnv のテスタビリティ
const FOO = process.env.FOOのようにモジュールロード時に環境変数を評価している場合、テスト内でvi.stubEnv('FOO', 'value')を呼んでも既にキャッシュ済みの値は変わらない。- 対策: スクリプト内で
process.env.FOOを関数呼び出し時に都度参照するよう変更すると、vi.stubEnvが正しく機能するようになる。 - モジュールレベルの const を廃止して関数内参照に変えることで、
vi.resetModules()+ 動的import()なしでテストできる。
-
外部 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して単体テスト可能にすること。
-
Vitest でクラスコンストラクタをモックする際は
functionキーワードまたはclass構文を使うvi.mock('exa-js', () => ({ default: vi.fn() }))のように SDK のデフォルトエクスポート(クラス)をvi.fn()でモックした場合、new Exa()はvi.fn()をコンストラクタとして呼び出す。- このとき
mockImplementation(() => ...)にアロー関数を渡すと 「is not a constructor」 エラーになる。mockReturnValue(...)も内部でアロー関数を使うため同じエラーが発生する(Vitest の警告:"The vi.fn() mock did not use 'function' or 'class' in its implementation")。 - 方法1 —
functionキーワード:mockImplementationにfunctionキーワードを使った関数を渡す(インスタンスごとに返すオブジェクトを変えたい場合に有効):vi.mocked(Exa).mockImplementation(function() { return { search: mockSearch } as unknown as InstanceType<typeof Exa>; }); - 方法2 —
class構文(Vitest v4 推奨):vi.mockのファクトリで実際のclassを返すとコンストラクタとして正しく動作する。メソッドチェーンを伴うクラス(例:joseのSignJWT)に特に有効:vi.mock('some-sdk', () => { class MockSomeClass { methodA() { return this; } methodB() { return Promise.resolve('result'); } } return { SomeClass: MockSomeClass }; }); InstanceType<typeof Exa>へのキャストはas unknown as InstanceType<typeof Exa>で行う(モックオブジェクトは全メソッドを持たないため直接キャストできない)。- 複数テストで呼び出しごとに返す値を変えたい場合は
setupExaMockヘルパーでmockSearchにmockResolvedValueOnceを積み上げ、mockImplementationでそのインスタンスを返す構造にするとシンプルになる(sync-status.test.ts参照)。
-
Server Component テストで子コンポーネントの props を検証する
await ServerComponent()は JSX ツリーを返すが、子コンポーネント(MemberCard等)関数は呼び出されない(React element descriptor として埋め込まれるだけ)。- そのため
vi.mocked(ChildComponent).mock.callsは常に空になる。vi.fn()に変えてもモック呼び出しは記録されない。 - 対策:
JSON.stringify(result)で JSX ツリーをシリアライズすると、props の boolean・string 値が"propName":valueの形式で含まれる。文字列検索で prop の値を検証できる:const html = JSON.stringify(await ServerComponent()); expect(html).toContain('"isRecentlyUpdated":true'); expect(html).not.toContain('"isRecentlyUpdated":false'); - 特定メンバーの props を個別検証するより、「全員が true/false になるように日付をモック」するシナリオでテストするとシンプルになる。
- 文字列(例:
dataLastUpdated)は JSX の children として埋め込まれた場合もJSON.stringifyに含まれるため、既存パターンexpect(html).toContain('2026-02-10')も同原理で動作している。 - 注意:
next/linkのLinkコンポーネントはJSON.stringifyで循環参照エラーを起こす。サーバーコンポーネントのテストでLinkを直接レンダリングする場合は、以下のようにvi.mockで差し替えること:
これによりvi.mock('next/link', () => ({ default: (props: Record<string, unknown>) => props, }));hrefなどの props がプレーンオブジェクトとしてシリアライズ可能になる。子コンポーネントをnullに差し替えるapp/page.tsxのテストパターンとは異なり、ページがLinkを直接使う場合に必要。 - 注意: JSX で変数を展開すると
childrenが配列になりtoContainで検索できない。<p>Disc {disc}</p>はchildren: ["Disc ", 1]としてシリアライズされるためtoContain('Disc 1')は失敗する。テンプレートリテラル{Disc ${disc}}にするとchildren: "Disc 1"の単一文字列になり検索できる(#360)。 - 注意: インライン JSX を別コンポーネントに切り出すとテキスト検索が壊れる。リファクタリングでラベル文字列(例:
'Spotify')をインライン JSX からExternalLinkBadge等のコンポーネントに移動すると、JSON シリアライズ結果にはコンポーネントの props({"type":"spotify",...})しか含まれなくなり、toContain('Spotify')が失敗する。対策: コンポーネントが受け取る props(type・url 等)を検証するよう切り替える(例:toContain('"type":"spotify"'))。ラベル文字列の表示検証は、その専用コンポーネントのテスト(ExternalLinkBadge.test.tsx等)に移譲する(#399)。
-
曜日依存ロジックを追加した場合の既存テスト修正パターン(#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()を忘れずに呼ぶこと。
-
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());
- 非同期保存後の表示を検証する際、「保存ボタンが消えた」を
-
非 React コードの setTimeout を含む非同期ループのテスト
- 再試行ロジックのような「async ループ内で
setTimeoutを使うコード」(React 非依存)はvi.useFakeTimers()を全面適用しても問題ない。 vi.runAllTimersAsync()はタイマーコールバックとマイクロタスクを繰り返し実行するため、複数回の再試行ループも一度の呼び出しで完走させられる。- 推奨パターン(再試行ロジックなど、タイマー完了後に動作を検証する場合):
vi.useFakeTimers(); const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: false, status: 429, ... }) // 1回目: 失敗 .mockResolvedValueOnce({ ok: true, json: async () => data }); // 2回目: 成功 vi.stubGlobal('fetch', fetchMock); const promise = fetchSomething(); await vi.runAllTimersAsync(); // 全タイマーを進める(再試行の待機をスキップ) const result = await promise; expect(fetchMock).toHaveBeenCalledTimes(2); afterEach(() => vi.useRealTimers())でリセットすること。vi.restoreAllMocks()はタイマーを戻さない。- 簡易パターン(ループ内ディレイをスキップするだけでよい場合・#596):
vi.stubGlobal('setTimeout', (fn: () => void) => { fn(); })をbeforeEachで設定すると全 setTimeout が即時実行される。afterEachでvi.unstubAllGlobals()済みなら追加のクリーンアップ不要。vi.useFakeTimers()+runAllTimersAsyncより設定が少なく、既存のawait fn()呼び出しをそのまま維持できる。
- 再試行ロジックのような「async ループ内で
-
vi.clearAllMocks()はモック戻り値もクリアするvi.mock('some-module', () => ({ fn: vi.fn().mockResolvedValue('default') }))のようにファクトリ内で初期戻り値を設定しても、beforeEachでvi.clearAllMocks()を呼ぶと戻り値もリセットされる。- 結果としてモック関数が
undefinedを返し、後続の処理が予期せず失敗する場合がある。 - 対策:
clearAllMocks()を使うdescribeブロックのbeforeEachで戻り値を再設定する:vi.mock('bcryptjs', () => ({ hash: vi.fn().mockResolvedValue('hashed') })); import { hash } from 'bcryptjs'; const mockHash = hash as ReturnType<typeof vi.fn>; beforeEach(() => { vi.clearAllMocks(); mockHash.mockResolvedValue('hashed'); // ← 再設定が必要 }); - 呼び出し履歴のみクリアしたい場合は
vi.clearAllMocks()の代わりに各モックの.mockClear()を使う方法もある。
-
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を使い変化を確認する。
- フォームバリデーションのテストで
-
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等)を実装すると、エッジケースの漏れを防ぎやすい。
-
モックオブジェクトは型に準拠した完全なオブジェクトを返すこと
{ 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: 'トークンが無効です' }); - ハードコードされたステータスコードを定数マップへのルックアップに変えるリファクタリングは、不完全なモックによる潜在バグを顕在化させるトリガーになることがある。
-
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 ベースのロケーターを使えるようにすることを優先すること。
-
E2E テスト(Playwright)— 安定化のための3つのアプローチ
- Cold Start の高速化:
playwright.config.tsのwebServer.commandをbun run dev --turboにして Turbopack を有効化すると動的コンパイルが大幅に速くなる。 - タイムアウト緩和:
playwright.config.tsでtimeout: 90000、navigationTimeout: 45000、expect.timeout: 15000を設定。特に重い業務フローにはtest.slow()を適用するとそのテストのみタイムアウトが3倍になる。 - 堅牢なセレクタとロード待機:
- 特定の行を絞り込んでからボタンをクリックする:
page.getByRole("row").filter({ hasText: "..." }).getByRole("button", { name: "詳細" }) - ページ遷移直後に「読み込み中」表示の消失を待つ:
await expect(page.getByText("読み込み中...")).not.toBeVisible()
- 特定の行を絞り込んでからボタンをクリックする:
- E2E テストは「成功/失敗」だけでなく「環境負荷への耐性」の視点も必要。
- Cold Start の高速化:
-
E2E テスト(Playwright)— テストと実装の乖離パターン
reuseExistingServer: trueのローカル開発では発覚せず、CI 初回実行で発覚しやすいパターンが3つある:- ロール不一致:
getByRole("tab")を使っていたが実装は<Link>(role="link")だった。 - ラベル・ロール不一致: ボタンのテキストや要素種別(
<button>vs<Link>)がコンポーネントと乖離していた。 - シードデータの固定日付: 固定された過去日付がデフォルトフィルター(当月 1 日〜今日)の範囲外で、一覧に表示されず失敗。
- ロール不一致:
- シードデータの日付は固定値を避け、実行日に対して動的に生成する:
const now = new Date(); const seedDate = new Date(now.getFullYear(), now.getMonth(), 1); // 当月1日 - CI を導入すると「環境差異による失敗」が顕在化する。CI の最初の実行は既存テストの健全性チェックとしても有効。
-
E2E テスト(Playwright)— API 経由データ事前準備でフォーム依存を排除する
- フォーム操作で前提データを作成する E2E テストは、フォームにバグがあると E2E レベルでも同じように失敗しタイムアウトする。
page.request.post()で API を直接呼び出してデータを準備し、page.reload()で画面に反映させることで、フォームの状態管理に依存しない安定したテストが書ける。- E2E がタイムアウトで失敗する場合は、フォームの Submit が API バリデーションエラーを返していないかブラウザのネットワークログで確認すること。
-
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 で落ちる」場合はブラウザ固有の挙動より先にデータ汚染を疑う。
-
Node.js 組み込みモジュール(
fs)のフォールバックパステスト(#624)vi.doMock('fs', factory) + vi.resetModules() + await import(...)の組み合わせは、Node.js 組み込みモジュールに対しては機能しない場合がある(Bun+Vitest 環境)。再ロードしても実モジュールが参照され続けるため、フォールバックパスが踏まれない。- 正しいアプローチ: トップレベルに
vi.mock('fs', async () => {...})を置き、vi.importActualで取得した実装をラップしたvi.fn()を返す。これによりsync-discography.ts内のimport { readFileSync } from 'fs'が差し替えられ、vi.mocked(readFileSync).mockImplementationOnce(...)でテストごとに挙動を制御できる。 - 既存テストへの影響分離:
vi.mock('fs', ...)を追加すると、実ファイルを読む既存テスト(例:syncDiscographyの正常系)も影響を受ける。manual-releases.jsonのように追加後にファイルが存在するようになったパスは、ファクトリ内でデフォルト返値(例:{ releases: [] })を設定し、既存テストをファイル内容から分離すること。mockImplementationOnceで上書きすればフォールバックテスト側は正しく動作する。 vi.clearAllMocks()はコール履歴のみリセット(実装は保持)。mockImplementationOnceのキューはクリアされるためbeforeEachでのclearAllMocks()と組み合わせてもテスト間の干渉は起きない。mockImplementationOnceはファイル名に関係なく「次の1回の呼び出し」を横取りする:readFileSyncがunits.json→manual-releases.jsonの順で呼ばれる場合、mockImplementationOnceでmanual-releases.jsonの返値を設定しても、実際には最初のunits.jsonの呼び出しが横取りされる。複数ファイルを読む場合はgetMockImplementation()でオリジナル実装を保持しmockImplementationにファイル名判定を組み込む:const originalImpl = vi.mocked(readFileSync).getMockImplementation(); vi.mocked(readFileSync).mockImplementation((...args: unknown[]) => { if (String(args[0]).includes('target-file.json')) { return JSON.stringify({ /* test data */ }); } return originalImpl?.(...(args as Parameters<typeof readFileSync>)); });
-
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も有効。エディタや自動編集で意図しない空白変更・末尾空白が混入していないか確認できる。 - 手順化:
- Issue本文・コメント・関連ノートを確認する。
- 影響範囲のファイルを特定する。
- 必要最小限の差分で実装する(フォーマット全体変更を避ける)。
- 回帰テストを追加または更新する。
bun run test <対象テスト>で局所確認する。bun run test/bun run lint/bun run type-checkを通す。git diff --checkとgit diffで不要差分がないことを確認する。
- このプロジェクトのテストは Vitest 前提。Bun の組み込みテストランナーを直接呼ぶ
-
lib/prisma.tsのモジュール初期化スローと間接インポートの罠(#856)lib/prisma.tsはDATABASE_URL未設定のままモジュールがロードされると即座にエラーをスローする(export const prisma = globalThis.prismaClient ?? createPrismaClient()がトップレベルで実行されるため)。- この影響は「直接インポート」だけでなく「間接インポート」にも及ぶ。テスト対象スクリプトが
lib/prisma.tsをインポートするモジュールを import していれば、テストファイル自体がlib/prisma.tsを踏む。 - 症状: テストが
DATABASE_URL 環境変数が設定されていませんで fail する。スタックトレースを見るとスクリプト本体でなく初期化時点のエラー。 - 対策: テストファイルの先頭(他の import より前)に必ずモックを宣言する:
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.ts→sync-discography.ts→lib/prisma.ts)でも同様にvi.mock('../lib/prisma', ...)が必要。 lib/releases.tsもlib/prisma.tsを使うため、getReleasesを呼ぶスクリプトのテストではvi.mock('../lib/releases', ...)も合わせて宣言すること。
-
@types/react 19 では
ReactElement.propsがunknown型になる(#1114)React.ReactElementの型パラメータPのデフォルトがanyからunknownに変更されたため、result.props.childrenのような直接アクセスはTS18046: 'result.props' is of type 'unknown'エラーになる。- Server Component テストで JSX ツリーを直接検証するヘルパー(テスト手法 #6・#9 のパターン)では、props を参照する前に明示的なキャストが必要:
const props = result.props as { children?: React.ReactNode }; const children = Array.isArray(props.children) ? props.children : [props.children]; - CI には type-check ジョブがないため、この種のエラーはローカルの
bun run type-checkで初めて発覚する。テストが実行時に通っていても型エラーが潜んでいることがある。
-
vi.mockのimportOriginalが別のモック対象モジュールを import している場合はvi.hoistedが必要(#1103)- トップレベルの
const mockPrisma = {...}をvi.mock('../../lib/prisma', () => ({ prisma: mockPrisma }))のファクトリで参照するパターン(collect-tiktok-posts.test.ts等)は通常動作するが、同じテストファイル内の別のvi.mockファクトリがimportOriginal()を使い、その実モジュールがlib/prismaを import している場合は破綻する。 - 原因:
importOriginal()がホイスト段階で実モジュール(例:lib/youtube)をロードし、その import チェーンが prisma モックファクトリを即時実行するため、const mockPrismaの初期化前に参照されCannot access 'mockPrisma' before initialization(TDZ) エラーになる。 - 対策: モックオブジェクトを
vi.hoistedでラップして初期化をホイスト段階に巻き上げる:const mockPrisma = vi.hoisted(() => ({ youtubePost: { findMany: vi.fn() }, })); vi.mock('../../lib/prisma', () => ({ prisma: mockPrisma })); vi.mock('../../lib/youtube', async (importOriginal) => { const actual = await importOriginal<typeof import('../../lib/youtube')>(); return { ...actual, fetchPlaylistId: vi.fn(), saveYoutubePosts: vi.fn() }; }); vi.hoisted内ではvi.fn()を使えるがmockResolvedValue等の初期値はbeforeEachで設定すること(vi.clearAllMocks()で戻り値もクリアされるため、どのみちbeforeEachでの再設定が必要)。
- トップレベルの
-
Windows + Bun 環境では
playwrightのchromium.launch()が無応答でハングする(#1271)/runスキルで新規ページの動作確認用に Playwright スクリプトをbun script.mjsで実行すると、chromium.launch()の呼び出しで応答なくハングし、タイムアウトするまで戻ってこない(エラーも出ない)。- ブラウザ本体(
~/.cache/ms-playwright/chromium-*)はインストール済みでも発生する。原因は Bun のプロセス起動(bunのchild_process実装)と Playwright の Chromium 起動シーケンスの相性問題と推測される。 - 対策: 同じスクリプトを
node script.mjsで実行すると正常に起動・操作できる。Playwright を使った手動検証スクリプトは Bun ではなく Node.js で実行すること(node --versionで利用可能か事前確認する)。 - この問題はアプリ本体の実装やテスト(Vitest)には影響しない。あくまで Playwright による手動ブラウザ操作スクリプトに限定される。
-
手動検証用 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 })で別コンテキストを作って同じ操作を流すと差分が出しやすい。
-
既存の共通関数に曜日依存ロジックを追加すると、その関数を間接的に通る無関係な既存テストまで曜日依存になる(#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を上書きして特定の曜日に絞り込む。
-
JSON.stringify(要素)で構造を検証する既存テストがある箇所に新しい条件付き要素を追加する際、既存要素の内側(同じ children 配列)に混ぜ込むと既存アサーションが壊れる(#1333)- 例:
<p>{count} 回</p>をtoContain('[3," 回"]')で検証している既存テストに対し、{count} 回 {condition && <a>...</a>}のように同じ<p>の中へ条件付き要素を追加すると、conditionが偽の場合でも children 配列にnullが追加され[3," 回",null]に変化し、既存の厳密な配列一致アサーションが壊れる。 - 対策: 新しい条件付き要素は既存の検証対象要素の内側に混ぜず、兄弟要素として追加する。既存要素の children 構造を変えなければ、既存テストの修正は不要になる。
- 例:
-
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動作確認中に「保存したはずの変更が画面に出ない」場合は、まずこの手順を試すこと。
- Server Component(
-
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回チェーンを持つテスト群(mockResolvedValueOnce2連鎖)が3回目の呼び出しで意図せずnullを受け取ったが、そのテスト自体はstatusHistoryの検証のみで新規フィールドを見ていなかったため無修正で通過した。- 教訓: 既存の
mockResolvedValueOnceチェーンを持つテストに新しいfetch呼び出しを追加するコード変更をした場合、「テストが通る」ことは「新しい呼び出しが意図通りのデータを受け取った」ことを保証しない。新しい呼び出しの戻り値を検証するアサーションが必要な場合は、そのテストでは明示的にモックを追加すること(本Issueではdescribe('Ameba 同期ステータスの書き込み')を新設して個別に検証した)。
-
共通判定関数のマッチ対象(入力文字列の範囲)を変更すると、その関数を間接的に呼ぶ他ファイルのテストフィクスチャが気づかれずに壊れる(#1412)
attributeTikTokPostToMembers(lib/tiktok-attribution.ts)のマッチ対象をキャプション全文からハッシュタグのみに変更した際、直接のテスト(lib/tiktok-attribution.test.ts)だけでなく、同関数を内部で呼ぶbuildTikTokSummaryEntry(scripts/workflow/collect-tiktok-posts.ts)のテストも、本文中に名前を書いただけのキャプション('メンバーAとの1枚'等)をフィクスチャに使っていたため軒並み失敗した。- 教訓: 実装#94の「同じ対象データへの判定ロジックの重複確認」の裏返しとして、共通関数のマッチ条件・入力範囲を変更する際は、直接のテストファイルを直すだけでなく、その関数を呼ぶ全ファイル(
grepで呼び出し元を確認)のテストフィクスチャも同じ前提(この場合は「名前は本文ではなくハッシュタグに書く」)に揃っているかを、テスト実行前に確認すること。フィクスチャの前提が古いままだとbun run test(プロジェクト全体)を実行して初めて発覚する。
-
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で理由まで踏み込んで記録されていなかったため、今回改めて特定した)。
-
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のテスト・動作確認スクリプトでプログラム的に値を設定する場合のみ注意が必要。
- UI動作確認スクリプトで時系列操作バーのスライダーを「マウスドラッグ」で操作しようとしたが、
-
単一対象への処理を複数対象のループに拡張すると、
mockResolvedValueOnceチェーンで固定回数のfetchを前提にした既存テストが軒並み壊れる(#1458)collect-tiktok-posts.tsのmain()を「公式アカウント1件を処理」から「OFFICIAL_TIKTOK_ACCOUNTSをループして複数アカウントを処理」に変更したところ、既存のmainテスト(Phase 1 系・#1422系)が軒並み失敗した。原因は各テストがvi.spyOn(global, 'fetch').mockResolvedValueOnce(...)を1〜2回分しかキューに積んでおらず、ループで追加された2アカウント目のfetch呼び出しでキューが枯渇しvi.fn()の既定動作(undefinedを返す)にフォールバックしたため。- 実装#29(#1387)の「try/catch で包まれていればテストは壊れない」ケースとは異なり、
fetchUserPostsはres.okを無条件で参照するためundefined.okで即座に例外が投げられ、テストがそのまま失敗する(サイレントな見逃しにはならない)。 - 対策:
.mockResolvedValueOnce(...)チェーンの末尾に.mockResolvedValue(emptyPageResponse)を追加し、明示的にモックしていない残りの対象(今回は2アカウント目)への呼び出しを空レスポンスにフォールバックさせる。mockResolvedValueOnceの宣言順序に関わらず、mockResolvedValueは常に「Once キューが尽きた後のデフォルト」として扱われるため、チェーンのどこに書いても同じ効果になる。 - 呼び出し回数アサーションも合わせて見直すこと:
toHaveBeenCalledTimes(N)のようなアサーションは対象数の増加分だけ機械的にずれる(1アカウント→2アカウントなら単純倍増とは限らず、ページネーション条件によって加算量が変わる)。mockImplementationで無条件hasMore: trueを返すMAX_PAGES上限テストのように、ループ対象が増えるとページ取得回数がその分単純に加算されるケースもあるため、各テストの意図(何をassertしたいか)に立ち返って再計算すること。
-
オプションオブジェクトへのフィールド追加で壊れるテストを洗い出す際、
grepはtoHaveBeenCalledWithだけでなくtoEqualも対象にすること(#1443)put()の呼び出しオプションにcacheControlMaxAgeフィールドを追加する作業で、影響を受けるテストを事前にgrep -l "toHaveBeenCalledWith" | xargs grep -l "allowOverwrite"で洗い出し、expect.objectContainingを使っているファイルを「影響なし」と判定した。patch-tampopo-unit-id.test.tsはこの絞り込みをすり抜けた。mockPut.mock.callsをループしexpect(call[2]).toEqual({ ... })で完全一致検証する書き方だったため、toHaveBeenCalledWithという文字列を含まず、grep 対象から漏れていた。実行するまで気づかず、テスト失敗で初めて発覚した。- 対策: オプションオブジェクトへのフィールド追加の影響範囲調査は
toHaveBeenCalledWithに限定せず、toEqual(およびtoStrictEqual)も横断して grep すること。完全一致アサーションは呼び出し形式(expect(mockFn).toHaveBeenCalledWith(...)かexpect(mockFn.mock.calls[N]).toEqual(...)か)が複数存在しうるため、アサーションメソッド名で絞るより、変更対象のフィールド名(例:allowOverwrite)とtoEqual/toHaveBeenCalledWithの両方を組み合わせて確認する方が確実。
-
見出し文言の変更を
.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 ブログ"'))。旧文言が他の文脈で残り続けることが仕様上正しいのか、実装漏れなのかを区別できる。
インフラ・設定
-
スクリプトのTypeScript設定 (Scripts TypeScript Config)
scripts/ディレクトリはtsconfig.jsonのexcludeに入っているため、@/エイリアスは使用不可。相対パス(例:../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を付与する。
-
Vercel 本番環境でのファイル書き込み制限
- Vercel のサーバーレス関数は
/var/task(プロジェクトルート)が読み取り専用。fs.writeFileSyncはEROFS: read-only file systemエラーで失敗する。 - 対策:
fs.writeFileSyncを独立した try-catch で囲み、code === 'EROFS' || code === 'EACCES'の場合は 503 を返してユーザーに明示的なメッセージを表示する。 - クライアント側では
response.okが false の場合にレスポンスボディを読んで API のエラーメッセージをalert()に渡す。 - この制限はローカル開発環境(
bun run dev)には影響しない。本番でデータ編集を可能にするには Vercel Blob / KV 等の外部ストレージが必要。
- Vercel のサーバーレス関数は
-
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チェックが実装されている場合、省略すると正常系テストが全て失敗する)。
-
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」メッセージが出る場合は、シークレットの注入漏れ(種別ミス含む)を疑うこと。
-
Vercel Blob
put()のallowOverwriteオプション@vercel/blobv1.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, });
-
ESLint 10 と
eslint-plugin-reactの互換性- ESLint 10 は
context.getFilename()メソッドを削除した(ESLint 9 では非推奨だったが残存していた)。 eslint-plugin-reactv7.37.x はこの API を使用しているため、ESLint 10 へのアップグレード時に以下のエラーが発生する:TypeError: Error while loading rule 'react/display-name': contextOrFilename.getFilename is not a functioneslint-config-nextはeslint-plugin-react: '^7.37.0'を依存に持つため、eslint-config-nextがeslint-plugin-reactを ESLint 10 対応版に更新するまで ESLint 10 は使用不可。- 対応: ESLint 10 への更新は upstream(
eslint-plugin-react/eslint-config-next)が対応次第実施する(Issue #144)。
- ESLint 10 は
-
GitHub Actions で Prisma を使うスクリプトを実行する場合は
bunx prisma generateが必須(#884)bun installだけでは Prisma クライアント(.prisma/client/)は生成されない。bunx prisma generateを明示的に実行しないとCannot find module '.prisma/client/default'エラーが発生する。- Prisma を使用するスクリプト(
sync-discography.ts,sync-genius-links.ts,sync-playlist-links.ts等)を実行するワークフローには必ずbun installの直後に以下のステップを追加すること:- name: Generate Prisma client run: bunx prisma generate DATABASE_URLはprisma generateの実行に不要(スキーマ読み取りのみ)。スクリプトが DB 接続を必要とする場合にのみenv:に渡せばよい。
-
server-onlyを Prisma ラッパー等のスクリプト共用ファイルに置いてはならない(#931)lib/prisma.tsにimport 'server-only'を追加すると、scripts/から Prisma をインポートするスクリプトが GitHub Actions(Bun 直接実行)でクラッシュする。server-onlyパッケージは Next.js がreact-serverエクスポート条件を設定した場合のみ no-op になる。Bun でスクリプトを直接実行すると同条件が設定されず、throw new Error(...)が実行される。Vitest も同様のためtest/__mocks__/server-only.tsでモックが必要になる。- 正しい対策: クライアントコンポーネントへの Prisma 混入を防ぐには、Prisma を使う関数(サーバーサイドのみ)と純粋関数を別ファイルに分離する(例:
lib/tiktok.tsとlib/tiktok-utils.ts)。クライアントコンポーネントは純粋関数のファイルのみをインポートすればよく、server-onlyは不要。 - まとめ:
server-onlyはスクリプト・ライブラリ層ではなく、Next.js のページ・レイアウト・Server Actions 等、Next.js バンドラーが必ず処理するファイルにのみ置く。
-
ワークフロースクリプトのログレベル制御は
lib/logger.tsで行う(#981)scripts/配下の収集スクリプトはlib/logger.tsのloggerを使い、console.logを直接書かないこと。- ログレベルは
LOG_LEVEL環境変数で制御する(デフォルト:INFO)。開発時のデバッグにはLOG_LEVEL=TRACEを設定する。 - レベル一覧(昇順):
TRACE/DEBUG/INFO/WARN/ERROR scripts/ディレクトリは@/エイリアス不可のため、インポートは相対パスで行う:import { logger } from '../lib/logger';- TRACE レベルはループ内の逐次処理(ツイート解析・DB 登録)の詳細追跡に使用する。INFO レベルは進捗サマリー(取得件数・登録件数)に使用する。
-
TypeScript 6.0 への移行条件と手順(#443)
- TypeScript 6.0 へのアップグレードは typescript-eslint の peerDependencies が
<6.0.0のまま対応されていなかったが、typescript-eslint@8.60.1 で>=4.8.4 <6.1.0に拡張された。ただしeslint-config-next@16.2.1が typescript-eslint@8.56.0 を固定しているため、現時点では 8.56.0 がインストールされ peerDependency 不整合が残る。bun はこの不整合を警告のみで処理するため機能上の問題はなく、全品質チェック(test・lint・type-check)通過済み。 - tsconfig.json やコードの変更は一切不要で、
package.jsonのtypescriptを^6.0.0に変更してbun installするだけで完了した(型エラーなし、全テスト通過)。 @types/nodeのメジャーバージョンは実行環境(Node バージョン)に合わせること。Node 24 運用の場合は^24.x。^25.xにすると Node 24 で存在しない API が型上は通ってしまうため注意。- 合わせ込み候補として
jsdomのメジャーアップ(^28→^29)とenginesフィールドの追加("node": ">=24")も同時実施。
- TypeScript 6.0 へのアップグレードは typescript-eslint の peerDependencies が
-
手動実行の
scripts/patch/*.tsは Blob 更新後にキャッシュを破棄しないと、ページによって反映タイミングがバラつく(#1286)app/members/[id]/page.tsxはgetMembersFromBlob()(lib/blob.ts)を直接呼んでおり、fetch(blobUrl, { next: { revalidate: 3600 } })で 1時間 の Next.js Data Cache がかかる。一方app/member-map/page.tsxはクライアント側でfetch('/api/members')を呼び、その Route Handler(app/api/members/route.ts)はfetch(blobUrl, { cache: "no-store" })で キャッシュなし。同じ Blob データでも経路によってキャッシュ戦略が異なるため、パッチスクリプト実行直後に検証すると「片方の画面では反映済み、もう片方では古いまま」という食い違いが起きる。- GitHub Actions から実行されるスクリプト(
sync-members.yml等)は実行後にcurl -X POST $APP_URL/api/revalidate(Authorization: Bearer $REVALIDATE_TOKEN)でキャッシュを破棄しているが、scripts/patch/*.tsはローカルから手動実行するため、この仕組みに乗っておらず、どれもキャッシュ破棄を行っていなかった。 - 対処:
patch-member-nicknames.tsに、Blob・data/members.jsonへの書き戻し完了後、APP_URL/REVALIDATE_TOKENを使って/api/revalidateを呼ぶ処理を追加した(未設定ならBLOB_READ_WRITE_TOKEN等と同様に早期にエラーとする)。patch-member-colors.tsにも同じパターンで追加した(#1298)。ただしこちらはdata/members.jsonへの書き戻しは行わず、Blob 更新後の/api/revalidate呼び出しのみを追加している(data/members.jsonの同期は別目的のためスコープ外とした)。 - 横展開(#1295): 重複コードを避けるため
revalidateCacheをscripts/patch/revalidate-cache.tsに共通ユーティリティとして切り出し、patch-member-nicknames.ts・patch-member-colors.tsをこれに追従させた上で、残る手動パッチスクリプト(patch-leader-generations.ts・patch-instructor.ts・patch-member-joining-routes.ts)にも同じ仕組みを導入した。これでscripts/patch/*.ts配下の Blob 書き戻しを伴うスクリプトは全てキャッシュ破棄まで一貫して行うようになった。 - キャッシュ戦略の統一(#1296):
app/api/members/route.tsのGETはPUT(保存時の読み込み→書き戻し。書き込み競合を避けるため意図的にno-store)の設定を流用していただけだったため、他のBlobアクセス経路と合わせてfetch(blobUrl, { next: { revalidate: 3600 } })に変更した。PUTは読み込み→書き戻しの整合性維持のためno-storeのまま維持している。 - 追加調査(#1298):
revalidatePath('/', 'layout')を呼んでもapp/members/page.tsx(一覧画面)だけ更新が反映されないケースを確認した。app/members/[id]/page.tsx(詳細画面)はexport const dynamic = 'force-dynamic'によりリクエストごとにサーバーで再実行されるため Data Cache 無効化がそのまま反映されるが、一覧画面は動的APIを使っていないため静的にプリレンダリングされ、revalidatePath後も古い Full Route Cache が Preview 環境で残るケースがあった(ブラウザのハードリロードでも解消せず、サーバー側キャッシュが原因と特定)。対処:app/members/page.tsxにもexport const dynamic = 'force-dynamic'を追加し、詳細画面と同じ挙動に揃えた。
-
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 ブランチを削除すること。
-
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で設定値を直接確認できる。
-
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で古い内容が返っても、書き込み自体が失敗しているとは限らない。
-
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のスコープ)
-
BlumeでMermaid図を描画するには
.mdxへの変換が必須。プレーンな.mdではコードブロックのまま表示される(#1435)node_modules/blume/src/markdown/index.tsのblumeMdxProcessor(mermaidPlugin()を含む)は@astrojs/mdx用プロセッサとして.mdxにのみ適用され、.mdはblumeMarkdownProcessor(mermaidPluginを含まない)が使われる。プレーンな.mdのまま ```mermaid ブロックを書いても、Mermaidの<blume-mermaid>要素へは変換されない- 対処: 図を含む設計書は
.mdから.mdxへgit mvでリネームする。リネーム後は他ファイルからの内部リンク([テキスト](旧ファイル名.md))も全て.mdxに更新する必要がある。リンク更新時は(?<![\w-])旧ファイル名\.mdのような否定後読み正規表現を使い、他の長いファイル名の部分文字列(例:screen-design.mdがlive-screen-design.mdの末尾と一致してしまう)を誤って書き換えないよう注意する
-
MDXは行内コード(バッククォート)で囲まれていない裸の
<tag>・{式}をJSX/JS式として解釈し、ビルドを壊す(#1435).md→.mdx変換時、表内の改行に使う裸の<br>(自己終了していない)や、プレースホルダー表記の裸の{メンバー名}のような記法がプレーンテキスト中に残っていると、MDXコンパイラがJSX/JS式として解釈しようとしてビルドエラーになる- 対処: 該当箇所をバッククォートで囲みインラインコードにする(既存ドキュメントの大半はこの慣習に従っていた)。
<br>のようなvoid要素は自己終了タグ<br />にする - コードフェンス(
```)・インラインコード(`)で囲まれた範囲は安全(MDXはコード範囲内をJSX解釈しない)。変換前に対象ファイルをコードブロック除去した上で裸の<・{が残っていないか機械的にチェックすると見落としを防げる
-
Blumeの
mermaid.initialize()はuseMaxWidthを無効化していないため、幅の広いMermaid図はコンテナ幅に縮小表示され読みにくくなる(#1435、blume 1.3.1で大幅改善・#26)node_modules/blume/src/components/content/mermaid-element.tsのmermaid.initialize({securityLevel: "strict", startOnLoad: false, theme: ...})にはflowchart: { useMaxWidth: false }等の指定がなく、Mermaid側のデフォルト(useMaxWidth: true)のままレンダリングされる- 横に広いflowchartを
subgraphでクラスタ分割しノード同士を疎结合にしても、クラスタ間に実エッジ(双方向的な関連)が残っていると、Mermaidのレイアウトエンジン(dagre)がクラスタを横並びに配置してしまい、根本的な解決にはならなかった(不可視エッジ~~~でのクラスタ間の縦積み強制も効果薄) - 真因はuseMaxWidth単体ではなく
<blume-mermaid>ラッパーのflex縮小だった: blume 1.3.0(upstream #137)でnode_modules/blume/src/markdown/mermaid.tsのクラスが"...flex justify-center overflow-x-auto"から"...flex overflow-x-auto [&>div]:w-full [&>div>svg]:mx-auto [&>div>svg]:block"に変更された。修正前はSVGのwidth:100%が本質的な幅を持たないため、shrink-wrapされたflexアイテムがブラウザのreplaced-element既定値300pxに潰れ、そこにviewBoxごと縮小されていた(useMaxWidthの値に関わらず全図が実質300px相当になっていた)。修正後はchild divがw-fullでコンテンツ幅いっぱいに広がり、useMaxWidth: true(デフォルト)の図はプローズ幅(数百px〜)まで正しくスケールされるようになった - 本リポジトリは#26でblume 1.3.1に更新済み(このFollow-up発生時点の1.2.0から2バージョン先)。上記修正を含む
bun patchによるuseMaxWidth: false強制は不要になった: 1.3.0以降、個別の図だけ自然サイズ+横スクロールにしたい場合は、Markdownソース側にMermaidのinitディレクティブ(%%{init: {'flowchart': {'useMaxWidth': false}}}%%)を書くだけで実現できる(ラッパーのoverflow-x-autoは維持されている)。全図一律のuseMaxWidth: falseパッチは横スクロール多発の副作用があるため、必要な図にだけ個別適用する方が望ましい
-
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ノイズを避ける
- このリポジトリの
-
frontmatterの
titleにMarkdownの見出しテキストをそのまま流用する場合、YAMLの安全性のため常時ダブルクォートで囲む(#1436)- 見出しテキストに
:(半角コロン+半角スペース)が含まれる場合(例:「調査レポート: X投稿から…」)、YAMLの非クォート文字列としては「マッピングのキー: 値」の区切りと誤認され、パースエラーになる - 見出しに
**太字**等のインライン装飾が含まれる場合、Blume本体の見出しテキスト抽出(ctx.textContent(node)、mdast-util-to-string相当)は装飾記号を含まないプレーンテキストを返すため、frontmatterへ転記する際も同様に装飾記号を除去してから使うこと(**text**をそのまま転記すると実際のderiveTitle()の出力と食い違う)
- 見出しテキストに
-
Blumeは相対的な内部リンク(
.md/.mdx拡張子付き)を自動でクリーンURLに解決しない。実際のルートは拡張子を除いた絶対パスで書く必要がある(#1437)docs/design/foo.mdxから同じdocs/design/配下のbar.mdxへ[bar](bar.mdx)のような相対リンクを書いても、Blumeにはこれを/design/barのようなクリーンURLへ書き換えるremarkプラグインが存在しない(grepでresolveLink/rewriteLink相当の実装を探しても見つからない)- ページのルートは「ファイル名を除いた絶対パス」(例:
docs/design/common/data-design.md→/design/common/data-design/)になるため、.md/.mdx拡張子付きの相対リンクをクリックすると、拡張子がそのままURLの一部として扱われ404になる(同一フォルダ内の兄弟ファイルへのリンクでも同様に404になる。「../を使った上位フォルダ越えの相対リンクだから」ではない) - 対処: 内部ドキュメントへのリンクは、相対パスではなく
/design/<カテゴリ>/<ファイル名(拡張子なし)>のようなサイト絶対パスで書く。サイドバー等Blumeが自動生成するナビゲーションリンクは元々この形式で出力されており、正しく機能する - 静的サイト全体をPlaywrightで実際にクロールし、各ページのリンクをクリックして200/404を確認するのが、この種の「ビルドは通るが実際にクリックすると壊れる」リンク切れを検出する最も確実な方法(
bun run buildだけでは検出できない)
-
フォルダ単位でサイドバーのカテゴリ名・並び順を制御するには
meta.tsを置く(#1437)- 各フォルダに
meta.ts(.js/.mjsも可)を置き、import { defineMeta } from "blume"; export default defineMeta({ title: "...", order: N })とすることで、そのフォルダのサイドバー表示名・並び順を指定できる meta.tsを置かないフォルダは、フォルダ名をtitleCaseしたもの(例:design→Design)がデフォルトの表示名になる。日本語の表示名にしたいカテゴリには必ずmeta.tsを置くことmeta.tsはビルド後のページ数・ルーティングには影響しない(純粋にサイドバー表示用のメタデータ)
- 各フォルダに
-
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)が通ってもアンカー切れは検出されないため、别途この種の検証が必要
- Blumeの見出しID生成は
-
Vercel Blobの
put()はcacheControlMaxAge未指定だとCDNエッジに1ヶ月キャッシュされる。恒久対策はcacheControlMaxAge: 60(指定可能な最小値)の明示(#1442)- #14で記録した「
put()直後でも CDN エッジキャッシュにより古い内容が返ることがある」現象の根本原因は、@vercel/blobのput()がcacheControlMaxAge未指定時にデフォルトで1ヶ月CDNエッジキャッシュする仕様だった(公式型定義コメントで確認)。allowOverwrite: trueで同一URLを使い回す書き込みでは、Next.js側でrevalidatePathを呼んでもBlobの公開URL自体が持つCDNエッジキャッシュまでは制御できない。 - 対処: 頻繁に書き換わるBlobへの
put()呼び出しにcacheControlMaxAge: 60を追加する。#14のキャッシュバスティングはあくまで検証時の回避策であり、恒久対策にはならない点に注意。 - リポジトリ全体で
put()呼び出しを洗い出したところ34箇所全てで未指定だった。毎日スケジュール実行・ユーザー操作で頻繁に呼ばれる箇所(sync-status.ts・daily-digest.ts・collect-instagram-posts.ts・app/api/members/route.ts、計5箇所)を本Issueで対応し、残り(手動実行専用ワークフロー・一回限りパッチスクリプト、29箇所)は#1443で追跡する。
- #14で記録した「
-
Electrobunデスクトップアプリに同梱されるBunバイナリは、システムにインストールしたBunとは独立した固定バージョン。上げるには
electrobun.config.tsのbuild.bunVersion指定が必要(#1494)electrobunパッケージ(v1.18.1時点)はdist-win-x64/bun.exeとして自前で固定バージョンのBunバイナリ(node_modules/electrobun/dist/api/shared/bun-version.tsのBUN_VERSION定数、確認時点で1.3.13)を同梱しており、bunx electrobun buildはデフォルトでこれをそのままアプリのbin/bun.exeにコピーする。ローカル環境のbun upgradeでシステムのBunを更新しても、この同梱バイナリには一切反映されない- Next.js 16(Turbopackビルド)の standalone サーバーを Bun 1.3.10〜1.3.13 で実行すると
TypeError: Expected CommonJS module to have a function wrapper(Bun側の既知バグ、oven-sh/bun#25609、1.3.14で修正)が発生し起動できない。この現象を「システムのBunをアップグレードすれば直る」と誤認しやすい - 対処:
desktop/electrobun.config.tsのbuildにbunVersion: "1.3.14"のように明示指定する。指定するとビルド時にGitHub Releases(https://github.com/oven-sh/bun/releases/download/bun-v<version>/bun-windows-x64-baseline.zip等)から該当バージョンをダウンロードしてキャッシュし、同梱バイナリとして使用する(ensureBunBinary/downloadCustomBun、node_modules/electrobun/src/cli/index.ts参照) - 同梱バイナリの実バージョンは、ビルド後に
desktop/build/<target>/<AppName>/bin/bun.exe --versionで確認できる
実装
-
Member Data Management (メンバーデータ管理)
- 全歴代メンバー(47名)の情報を
data/members.jsonに集約。 idはURLセーフなローマ字表記(例:nakazawa-yuko)を使用。statusはActive(現役)またはOG(卒業生)で管理。卒業(OG)は不可逆であり、一度OGになったメンバーをActiveに戻してはならない。スクリプトからstatusを更新する際はupdateMemberStatus()ガード関数を使用すること。generationフィールドを追加し、期順でのソートやフィルタリングを容易に。- 日付フォーマットは
YYYY-MM-DDに統一。卒業予定や未定の場合はnullまたは特定の日付、ハイフン等で対応。 - squash merge 時のデータ消失リスク:
data/members.jsonのような大きな JSON ファイルを含む PR を squash merge すると、ブランチ内の中間コミット(データ修正 fix など)がコンフリクト解消時に失われることがある。PR マージ前にgit show <squash-commit>:data/members.jsonで対象メンバーの値を必ず確認すること(過去に羽賀・横山・北川・nakazawa-yuko で発生)。
- 全歴代メンバー(47名)の情報を
-
JSON Data Handling (JSONデータの扱い)
- メンバー数が多いため、IDの重複や構文エラーに注意。
latestStatusには、2026年2月時点の最新情報を反映。今後の自動更新エンジンのベースとなる。statusHistoryは省略可能(?)にし、既存データへの後方互換を保つ。初期化時は bun スクリプトでstatusHistory: []を全メンバーに一括追加する(JSONを直接編集するより安全で速い)。
-
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("レスポンスがオブジェクトではありません"); }
- TypeScript の型定義で
-
JST ローカル日付の取得と Vitest のタイムゾーン設定
new Date().toISOString().split('T')[0]は常に UTC 日付を返すため、JST(UTC+9)の深夜0〜8時台に操作すると前日の日付が記録されるバグの原因になる。- 対策:
new Intl.DateTimeFormat('sv', { timeZone: 'Asia/Tokyo' }).format(new Date())を使うこと。ロケールsv(スウェーデン語)が YYYY-MM-DD 形式を返し、timeZone指定により実行環境の TZ 設定に依存せず常に JST 日付が得られる(#63)。 - 注意:
new Date().toLocaleDateString('sv')でも同様の形式を返すが、実行環境のシステムタイムゾーンに依存する。GitHub Actions のubuntu-latest(UTC)上で UTC 15:00〜23:59 に手動実行すると JST の「昨日」が記録されるリスクがある。 - テスト環境のタイムゾーン設定:
vitest.config.tsのtest.env.TZに'Asia/Tokyo'を設定すると、テスト実行時の Node.js タイムゾーンが JST になり、JST 境界値テストが可能になる。// vitest.config.ts test: { env: { TZ: 'Asia/Tokyo' } } - JST 境界値テストパターン(UTC 前日 15:30 = JST 当日 00:30):
vi.setSystemTime(new Date('2026-02-22T15:30:00.000Z')); // JST では 2026-02-23 // expect lastUpdated to be '2026-02-23' - サーバーサイドのタイムスタンプ生成テスト:
vi.useFakeTimers({ toFake: ['Date'] })+vi.setSystemTime()を使うと、サーバー側コードのnew Intl.DateTimeFormat('sv', { timeZone: 'Asia/Tokyo' }).format(new Date())も偽装した日時を返す。API Route(route.ts等)のタイムスタンプ生成が正しく JST 日付を生成することを、クライアントから送られた日付と区別して検証できる(#141)。 - JST 日付グルーピングテストのタイムスタンプ選定(#475): UTC タイムスタンプをテストデータに使う場合、UTC
20:00:00Z以降は JST で「翌日」になる(例:2026-03-28T20:00:00Z= JST2026-03-29T05:00:00)。JST で「同日」の投稿として期待するテストでは、UTC00:00:00Z〜14:59:59Zの範囲(JST09:00:00〜23:59:59)のタイムスタンプを使うこと。
-
Tavily
include_domainsによる公式SNS優先検索performSearchにincludeDomains?: string[]を追加すると、Tavily のinclude_domainsパラメータとして渡せる。- URL から
new URL(url).hostnameでドメインを抽出し、不正なURLはtry/catchで空配列にフォールバックする。 - テストで
include_domainsが含まれていないことを確認する場合はexpect(body).not.toHaveProperty('include_domains')を使うとシンプル。 - 空配列(
[])の場合はinclude_domainsをリクエストボディに含めないこと。Tavily の仕様上、空配列を渡すと意図しない挙動になる可能性がある。
-
日本語コンテンツの検出パターン
- 外部API(Tavily等)の検索結果が英語の場合、日本語キーワードを追加したクエリ(例:
${name} 最新情報 日本語)で再検索することで日本語コンテンツを取得できる場合がある。 - 再検索でも日本語が取得できない場合は空結果を返してスキップするのが適切(英語コンテンツを蓄積しない)。
- 言語判定の実装方針(#188): ひらがな文字(U+3040-U+309F)の出現率で日本語文章かどうかを判定する。
- ひらがなは日本語固有の文字であるため、英文中のカタカナ語や漢字交じりの中文(中国語)を正しく除外できる。
- 実装:
(content.match(/[\u3040-\u309f]/g) ?? []).length / content.length >= 0.08 - 閾値 8% の根拠: カタカナ・漢字が多い日本語テキスト(例: 「タレント活動を継続中。」ひらがな率 ≈ 9%)も正しく検出できるギリギリの値。英文・中文は 0% なので余裕を持って除外できる。
- テストで検証すべきケース: ひらがなのみ、カタカナ+ひらがな混在、漢字のみ(false)、英文+カタカナ(false)、中文(false)。
- 外部API(Tavily等)の検索結果が英語の場合、日本語キーワードを追加したクエリ(例:
-
sync-status.tsのドライランと副作用の制御performSearchはSEARCH_API_KEY未設定時に即座に空結果({ content: "", sources: [] })を返す(実際の API 呼び出しは行わない)。fetchMemberStatusで SNS 検索結果が空の場合にofficialSns[n].active = falseのような副作用を発生させる場合、process.env.SEARCH_API_KEYが設定されているかを確認してから実行する必要がある。設定されていないドライランで副作用を発生させると、実際には API を呼んでいないのにデータが書き換わるバグになる。// 正しいパターン(#185: ! 不要、#186: console.warn もブロック内へ) if (process.env.SEARCH_API_KEY) { member.officialSns.forEach((entry) => { if (entry.active) entry.active = false; }); console.warn(` → SNS限定検索でコンテンツを取得できませんでした: ...`); }- テストでは「SEARCH_API_KEY 未設定時に副作用(active の変更)が発生しないこと」も検証すること。
-
React イベントハンドラの型:
React.FormEventは非推奨 →React.SyntheticEventとe.currentTargetを使うonInvalid/onInputのハンドラ引数型としてReact.FormEvent<T>を使うと TypeScript が非推奨警告を出す。- 代わりに
React.SyntheticEvent<T>を使うと解消できる。さらにe.targetのキャストも不要になるe.currentTargetを利用するとより安全:const makeValidationHandlers = (message: string) => ({ onInvalid: (e: React.SyntheticEvent<HTMLInputElement>) => { e.currentTarget.setCustomValidity(message); // キャスト不要 }, onInput: (e: React.SyntheticEvent<HTMLInputElement>) => { e.currentTarget.setCustomValidity(''); }, }); e.currentTargetはハンドラが付与された要素自身を指すため、(e.target as HTMLInputElement)のキャストが不要になる(#213)。
-
SNS判定は「ホスト一致」だけでは不十分(#223)
ameblo.jpやx.comのような共有ドメインでは、hostnameだけで公式SNS判定すると他アカウントの投稿を誤って「公式更新」と判定してしまう。- 対策:
hostnameに加えて「アカウント識別子(通常はパス先頭セグメント)」まで一致させる。 例:https://ameblo.jp/member-a/...はmember-aとmember-bを区別する。 twitter.comとx.comのようなドメイン移行はホスト正規化テーブルで吸収する。- 判定ロジックは
lib/sns.tsに集約し、UI(MemberCard)とバッチ(sync-status.ts)で共通利用することで仕様ズレを防ぐ。
-
バッチ実行結果の可観測性をデータに持たせる
- 毎日実行されるジョブでは「今の表示がいつの判定か」が最重要になるため、
lastCheckedAtを必須に近い扱いで保存する。 - 画面では以下を最低限表示すると運用確認が速い:
- 一覧: 最終バッチ実行時刻 + 成功/エラー/未確認件数
- 詳細: 状態、前回確認日時、判定理由(
reason)
reasonは API/スクレイピング失敗原因(例: timeout, 404)を短文で残し、一次調査をUIから開始できるようにする。
- 毎日実行されるジョブでは「今の表示がいつの判定か」が最重要になるため、
-
snsCheck導入時の後方互換戦略- 既存Blobデータに
snsCheckがない期間を考慮し、UI側でフォールバック導出(resolveSnsCheck)を用意すると段階移行しやすい。 - ただしフォールバックは暫定ロジックのため、最終的にはバッチで全メンバーに
snsCheckを付与した状態に揃える。 - 新規型追加時は
types/member.tsと関連テスト(UI・バッチ・ヘルパー)を同時更新し、型安全と挙動の一致を維持する。
- 既存Blobデータに
-
部分日付(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 を先に検索・適用することで、自動ロジックを変えずに個別ケースを補正できる。
-
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 フレーム遅れるため、即座の同期が必要な場合はkeyprop によるコンポーネントリセットを検討する。- 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のように導出結果そのものと比較すれば、依存する他の値がいつ変化しても正しく追従する。
- 親コンポーネントの props 変更に合わせてローカル state を同期するために、
-
複数リソースの一括登録はアトミックに行う(トランザクション)
- 親レコードと子レコードを別々に作成する際、途中でエラーが発生するとデータ不整合が生じる。
- ORM を使う場合は
$transaction(Prisma)等でラップしてアトミック性を保証する。ORM なしの場合も DB のトランザクション機能(BEGIN/COMMIT/ROLLBACK)を活用すること。 createMany(Prisma)等の一括挿入 API を使うと、子レコードを 1 件ずつ挿入するより効率的。- 教訓: 親子関係のあるデータ作成は「全件成功か全件ロールバック」が原則。片方だけ成功するケースを許容してはならない。
-
部分更新 API のパターン(undefined・空文字列・値の3種類を区別する)
undefined: フィールドを更新しない(そのまま保持)"": フィールドを明示的に空にする(DB では NULL)"text": フィールドをその値に更新- この3状態を区別することで、「更新しない」と「空にする」を明示的に操作できる API になる:
if (description !== undefined) { updateData.description = description === '' ? null : description; } - 「全フィールドが
undefined」の場合は早期リターンでエラーにするとバリデーションが明確になる。 - ステータスチェック(例: 下書きのみ更新可能)は「存在確認 → 権限確認 → 状態確認」の順で行うとエラーレスポンスが一貫する。
-
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 上の旧データが引き継がれるか」が変わる。
- Blob の書き込みフォーマットを変更した場合(例: フラット配列 →
-
GitHub Actions の
push+pathsフィルターはタグ push で無視される(#419)- タグを push する際、GitHub は
beforeSHA をゼロ値として扱い、ファイル差分を判定できない。 - このため
on.push.pathsフィルターがスキップされ、ワークフローが無条件で実行されてしまう。 - 対策:
on.push.branchesを明示することでタグ push をトリガー対象から除外する:on: push: branches: - main # ブランチのみ対象(タグ push はここで除外される) paths: - 'data/foo.json' - ワークフローが追加されたバージョン以降のタグ push がすべてトリガーになるため、意図しない実行に気づきにくい点に注意。
- タグを push する際、GitHub は
-
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 と表示テキスト)ことを検証できる。
- OSC 8 エスケープシーケンスを使うと、Windows Terminal・iTerm2 等の対応ターミナルでクリックしてブラウザを開けるリンクを出力できる:
-
YouTube Data API v3 のクォータ超過を検出して処理を中断する(#427)
- YouTube API の 403 レスポンスには「権限不足(
forbidden)」と「デイリー上限超過(quotaExceeded/userRateLimitExceeded)」の2種類がある。両方ともres.ok = false,status = 403だがエラー理由が異なるため、ボディを読んで判別する必要がある:if (res.status === 403) { try { const body = await res.json() as { error?: { errors?: { reason?: string }[] } }; const reason = body?.error?.errors?.[0]?.reason; if (reason === 'quotaExceeded' || reason === 'userRateLimitExceeded') { throw new YouTubeQuotaExceededError(); } } catch (e) { if (e instanceof YouTubeQuotaExceededError) throw e; // JSON パース失敗は無視(通常の 403 と同様に処理) } } - 上位ループでは
try/catchでYouTubeQuotaExceededErrorを受け取りbreakすることで、クォータ超過後の無駄な API 呼び出しをなくせる。 - さらに
maxCallsパラメータで上限件数を事前に設けると、クォータ到達前のプロアクティブな打ち切りも可能。 - テストでは
jsonメソッド付きのモックレスポンスを使い、クォータ超過ボディをシミュレートする:vi.fn().mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden', json: vi.fn().mockResolvedValue({ error: { errors: [{ reason: 'quotaExceeded' }] } }), })
- YouTube API の 403 レスポンスには「権限不足(
-
Next.js バージョン更新時の確認ポイント(#375 / #1078)
- Next.js のマイナーバージョンを上げる際は
bun add next@X.Yを実行後、eslint-config-nextも同バージョンに揃えること(自動更新されない)。 - 更新後は
bun run devで起動し、Turbopack 関連のエラーログが出ないことを目視確認すること。 - Turbopack
performance.measure負値バグ(#25 / upstream PR #88688)は Next.js 16.2.1 で再現しないことを確認済み(解消されたとみられる)。 - transitive依存のpeerDependency不整合は
bun addだけでは解消されないことがある(#1078):eslint-config-nextが間接依存するtypescript-eslintはセマンティックレンジ(例:^8.46.0)を満たしていれば bun は既存のロック済みバージョンを維持し、レンジ内でより新しいバージョンが存在してもリゾルブし直さない。eslint-config-next更新後も peerDependency 警告(例:typescriptバージョン不整合)が解消されない場合は、該当パッケージを明示的にbun update <package> --latestで更新すること。 - Next.js 16.3 では
next dev実行時にAGENTS.mdを自動生成・更新する機能が追加された(<!-- BEGIN:nextjs-agent-rules -->〜<!-- END:nextjs-agent-rules -->マーカー範囲のみ upsert、マーカー外やCLAUDE.md等の別ファイルは変更されない安全な実装)。差分が出るのは想定通りなので、そのままコミットしてよい(next.configのagentRules: falseでオプトアウトも可能)。
- Next.js のマイナーバージョンを上げる際は
-
スクリプト内ローカル型に
[key: string]: unknownがある場合の型キャスト回避(#472)- スクリプト固有の型(例:
build-playlist-index.tsのRelease)はtypes/の型定義を import せず、ファイル内にインラインで定義している場合がある。 - このような型に
[key: string]: unknownインデックスシグネチャが付いている場合、実際に存在するフィールドもunknownとして扱われ、直接インデックスアクセスや文字列メソッド呼び出しで型エラーが出る。 - 対策:
as stringでキャストするのではなく、使用するフィールドを型定義に明示的に追加する。これにより型安全性が向上し、不要なフォールバック (?? '...') も除去できる:// Before: as string キャスト+フォールバック type Release = { id: string; tracks?: Track[]; [key: string]: unknown }; const formatA = (a.release.format as string) ?? 'other'; // After: フィールドを型定義に追加 type Release = { id: string; format: string; releaseDate: string; tracks?: Track[]; [key: string]: unknown }; const prioA = FORMAT_PRIORITY[a.release.format] ?? FORMAT_PRIORITY.other;
- スクリプト固有の型(例:
-
複数ファイルにまたがる共有型の重複定義に注意(#509 / #510)
types/comparison.tsにReleaseFormatがtypes/release.tsとは独立して定義されていた(#491でドメイン分離した副産物)。- 共有型をユニオンに拡張する際、片方の更新を忘れると型エラーや実行時の不整合が生じる。
- 対策: 共有型は一箇所で定義し、他のファイルは
import type { X } from '...'+export type { X }で re-export する。// types/comparison.ts import type { ReleaseFormat } from './release'; export type { ReleaseFormat };
-
import.meta.url+fileURLToPathは Windows では必須だが jsdom テストでは要モック(#543)new URL('./foo.json', import.meta.url).pathnameは Windows Bun 実行時に/D:/...(先頭スラッシュ付き)を返し、writeFileSyncがENOENTでクラッシュする。- 修正:
fileURLToPath(new URL('./foo.json', import.meta.url))を使うと OS ネイティブなパス(D:\...)が得られる。 - テスト対応: Vitest の jsdom 環境では
import.meta.urlがfile:スキームでないためfileURLToPathがERR_INVALID_URL_SCHEMEをスローする。vi.mock('url', ...)でfileURLToPathをモックすること:vi.mock('url', () => { const fileURLToPath = vi.fn((url: URL | string) => (typeof url === 'string' ? url : url.pathname)); return { fileURLToPath, default: { fileURLToPath } }; }); defaultキーも返さないと Vitest が「No ‘default’ export is defined」エラーを出す点に注意。
-
メンバー紐づけロジックに実質基準日を導入するとテスト設計が根本的に変わる(#514)
- 加入日を基準に “翌月1日以降の最初のシングル” を実質基準日として採用すると、単一リリースしか渡さないテストの大半が成立しなくなる。
- 理由: 実質基準日の算出自体がリリース一覧から最初のシングルを探す処理に依存するため、シングルが含まれていないとすべてのリリースが除外される。
- 対策: テストは「シングルを先頭に置いて effectiveJoinDate を確定させ、その後に検証対象のリリースを渡す」構成にする。各テストに
makeRelease({ format: 'single', ... })を含めることを意識する。 - precision=
'none'(日付不明)も従来は low 信頼度で in range だったが、このロジック変更で in range=false(除外)に変わった。テスト上も「除外される」に更新すること。
-
MusicBrainz の artist-credits 取得と複数期ユニットの年次照合(#562)
fetchTracksForReleaseの API エンドポイントにartist-creditsを追加(inc=recordings+artist-credits)することでトラックごとのアーティスト名を取得できる。artist-credit[0].name(クレジット表記名)が存在する場合はartist-credit[0].artist.name(正式名)より優先する(例: アルバム掲載名と正式名が異なる場合がある)。- 複数期がある日本語ユニット名(例: “タンポポ(第1期)”)の照合は「括弧前のベース名でグループ化 → リリース年が
activeFrom〜activeToに含まれる期を選択」で行う。境界年(例: activeTo=2002 と activeFrom=2002 が重なる)ではactiveFromが最大の期(最新期)を採用する。 - これにより、既存の
Track.unitIdが設定済みのトラックは再処理で上書きされない(手動修正保護)。
-
Prisma 7 の設定構造と Neon アダプターの使い方(#609)
- Prisma 7 では
schema.prismaのdatasourceブロックにurl = env("...")を書けなくなった。接続 URL はprisma.config.tsのdatasource.urlに移動する:// prisma.config.ts import { defineConfig } from 'prisma/config' export default defineConfig({ schema: './prisma/schema.prisma', datasource: { url: process.env.DIRECT_URL ?? process.env.DATABASE_URL ?? '' }, migrations: { seed: 'bun run prisma/seed.ts' }, }) PrismaNeon(@prisma/adapter-neon)のコンストラクタはPoolインスタンスではなくPoolConfigオブジェクトを受け取る:// NG: new PrismaNeon(pool) // Pool インスタンスは不可 // OK: new PrismaNeon({ connectionString }) const adapter = new PrismaNeon({ connectionString: process.env.DATABASE_URL }) const prisma = new PrismaClient({ adapter })prisma.config.tsのmigrate.adapter()は存在しない(旧ドキュメントの誤り)。マイグレーション用 URL はdatasource.urlのみ。- Neon の pooled URL(
DATABASE_URL)と direct URL(DIRECT_URL)を使い分ける場合: マイグレーションにはDIRECT_URL、クライアントにはDATABASE_URLを渡す。
- Prisma 7 では
-
Prisma CLI は
.env.localを読み込まない(#638)- Next.js の規約では環境変数を
.env.localに書くが、Prisma CLI(prisma migrate deploy等)が自動読み込みするのは.envのみ。 .env.localのみ存在する環境ではprocess.env.DATABASE_URLがundefinedになり、prisma.config.tsの接続 URL が空文字になって以下のエラーが発生する:Error: Connection url is empty. See https://pris.ly/d/config-url- 対策:
prisma.config.tsでdotenvを使って.env.localを明示的に読み込む:import { defineConfig } from 'prisma/config' import { config } from 'dotenv' // Prisma CLI は .env.local を自動読み込みしないため明示的にロードする config({ path: '.env.local' }) export default defineConfig({ ... }) dotenvは Next.js の依存として既にインストール済みのため、追加インストール不要。bun run db:seedは Bun が.env.localを自動読み込みするため対応不要。
- Next.js の規約では環境変数を
-
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()ヘルパーを再利用する。
-
declare global { var ... }と ESLintno-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」警告が出る。
- TypeScript の
-
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: [...] })を呼ぶ。
- react-leaflet は SSR 非対応のため、Server Component 内で直接
-
@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'のページファイル内で行うと良い(ページスコープに限定できる)。
-
先行 PR ブランチをベースにした PR は
Closes #XXXによる Issue 自動リンクが機能しない(#669)- GitHub の「
Closes #XXXで Issue を自動リンク・クローズ」機能は、PR がデフォルトブランチ(main)にマージされる場合にのみ有効。 /next-issueの「一緒に対応できる項目」などで先行 PR のブランチをベースにした PR を作成すると、Closes #XXXを本文に記載しても GitHub の Development 欄に Issue リンクが表示されない。- この場合は PR 画面の「Development」欄から手動で Issue を紐付けること。
- GitHub の「
-
Ameba グループブログの RSS メンバー振り分けはフォールバック設計にする(#679)
morningmusume16ki等の複数メンバー共有ブログは RSS に全員の投稿が混在するため、parseAmebaRssにメンバー名フィルターを追加した。- ただし
fetchAmebaRssは個人ブログか共有ブログかを区別できないため、「メンバー名を含む item を優先し、見つからない場合は先頭 item にフォールバック」とした。 - フォールバック時の挙動: 個人ブログ(名前が本文に出ない)→ 先頭で OK。共有ブログでメンバーが最近投稿していない→ 他メンバーの投稿が入る(許容)。
- 個人ブログのテストでは member name を RSS 内に含めなくても通るが、グループブログのテストでは member name を description/title に明示すること。
-
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を参照。
-
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-...}として登録される
- Inno Setup スクリプトの
-
Next.js standalone サーバーの
.env.local読み込みと Bun Worker でのパス解決(#705)output: 'standalone'ビルドで生成される.next/standalone/server.jsは、process.cwd()(実行時のカレントディレクトリ)から.env.localを読み込む。server.jsと同じディレクトリをcwdに指定すれば.env.localを自動ロードできる:Bun.spawn([bunExe, serverJs], { env: { ...process.env, DESKTOP_MODE: "1", HOSTNAME: "localhost", PORT: port }, cwd: standaloneDir, // server.js と .env.local を同じディレクトリに置く });- standalone の静的ファイルは手動コピーが必要:
next buildでは.next/static/は standalone ディレクトリに含まれない。サーブ前に.next/static/→.next/standalone/.next/static/およびpublic/→.next/standalone/public/をコピーする(またはインストーラでコピーする)。 - Bun Worker での
import.meta.dir: Worker のエントリポイントファイルのディレクトリを返す。インストール済みアプリでは{app}/Resources/app/bun/となるため、上位ディレクトリへの相対パスでアプリ構造をたどれる:const resourcesDir = join(import.meta.dir, "..", ".."); // {app}/Resources/ const bunExe = join(resourcesDir, "..", "bin", "bun.exe"); // {app}/bin/bun.exe output: 'standalone'はDESKTOP_MODE=1時のみ有効にすると Vercel デプロイに影響を与えない(next.config.tsでprocess.env.DESKTOP_MODE === '1'で条件分岐)。
-
Vitest で
vi.mockファクトリからawait importでモジュールを参照するパターン(#617)vi.mock(...)のファクトリ関数はモジュールのインポートより前にホイストされるため、ファクトリ内でモジュールレベルのconstを参照すると TDZ エラーになる。- 対策: テスト関数または
beforeEach内でawait import('...')を使ってモジュール参照を取得する。トップレベルの static import は使わない:// NG: static import は vi.mock ホイスティングと競合して TDZ エラー import { put } from '@vercel/blob'; // OK: テスト関数内で動的に取得 it('test', async () => { const { put } = await import('@vercel/blob'); expect(vi.mocked(put)).toHaveBeenCalled(); }); beforeEachをasyncにして共通セットアップでputを取得し、各テストでもawait importで取得すればvi.mocked(put)が正しく機能する。vi.clearAllMocks()は呼び出し履歴だけでなくモックの返り値もクリアするため、beforeEachでmockResolvedValueをセットし直すこと。
-
JSX 数値 children は JSON.stringify で引用符なしの数値になる(#617)
- Server Component の JSX ツリーを
JSON.stringifyするテストで数値を検証する場合、数値型の children は"children":"81"ではなく"children":81(引用符なし)としてシリアライズされる。 expect(html).toContain('"children":81')のように数値型で比較すること。
- Server Component の JSX ツリーを
-
デスクトップアプリのバージョン体系は Web アプリとは独立している(#711)
- root の
package.json(例: 3.3.0)は Web アプリおよび Windows インストーラのバージョン。build-installer.ps1がこれを読んで/DAppVersionとして Inno Setup に渡す。 desktop/electrobun.config.tsとdesktop/package.jsonは Electrobun アプリ(macOS アプリバンドル)のバージョン(例: 0.0.2)。これらは独立して管理する。- バージョンを上げる際はどちらのバージョンを指しているか確認すること。
- root の
-
Bun スクリプトで PNG から ICO ファイルを生成できる(#712)
- ICO フォーマットは「ICONDIR(6 bytes)+ ICONDIRENTRY(16 bytes × 画像数)+ 画像データ」のシンプルな構造。PNG をそのまま埋め込める(PNG 埋め込み ICO)。
DataViewで ICO ヘッダを構築し、PNG バイト列を追記するだけで有効な.icoが生成できる:const pngBytes = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); const buf = new ArrayBuffer(6 + 16 + pngBytes.length); const view = new DataView(buf); view.setUint16(0, 0, true); view.setUint16(2, 1, true); view.setUint16(4, 1, true); // ICONDIR view.setUint8(6, size); view.setUint8(7, size); // width / height (0 if > 255) view.setUint16(10, 1, true); view.setUint16(12, 32, true); // Planes / BitCount view.setUint32(14, pngBytes.length, true); // BytesInRes view.setUint32(18, 22, true); // ImageOffset new Uint8Array(buf, 22).set(pngBytes); await Bun.write(icoPath, buf);- macOS 用
.iconsetはsharp(Next.js 依存として利用可)で各サイズへリサイズして生成できる。ソース画像は 512px 以上が望ましい(低解像度からの拡大はドット感が出る)。
-
バッチ収集側で対象を絞り込み、表示側はデータ駆動にする(#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 の責任を明確に分離)。
- バッチ(sync-discography 等)で URL を収集する際、対象フォーマットを絞り込む(例:
-
再取得バッチでの「空結果 = 意図的削除」を正しくキャッシュ判定する(#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;
- 初回取得: 結果が空のとき cache を更新しない(
-
Prisma マイグレーションの作成手順: スキーマ修正 → コマンド生成(#850)
- 正しい手順:
prisma/schema.prismaを修正してからbunx prisma migrate dev --name <name>を実行し、マイグレーション SQL を自動生成する。 - 手書き SQL は厳禁。Prisma が生成する制約名(
テーブル名_カラム名_key等)と手書きの命名が食い違うと、後続のprisma migrateが差分を誤検知する原因になる。 - DB 接続がない環境では
migrate devが実行できないため、その場合はユーザーに状況を伝えて判断を仰ぐこと。
- 正しい手順:
-
PostgreSQL の camelCase カラム名は CREATE INDEX でも必ずダブルクォートで囲む(#853)
- PostgreSQL は識別子を 小文字に正規化するため、
CREATE INDEX ... ON table (venueId)と書くとvenueidとして解釈される。 "venueId"と二重引用符で囲まれたカラムと不一致になりcolumn "venueid" does not existエラーが発生する。- 手書きマイグレーション SQL では常に
"venueId"のようにダブルクォートを付けること(Prisma 自動生成 SQL は正しくクォートする)。
- PostgreSQL は識別子を 小文字に正規化するため、
-
Prisma migrate dev: マイグレーションファイル変更後のチェックサムズレを DB 更新で解消(#853)
migrate devが「The migration was modified after it was applied.」と報告する場合、_prisma_migrationsテーブルのチェックサムが変更後のファイルと一致していない。- データを保持したまま解消する手順:
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 を取得するbunx prisma db execute --stdinでUPDATE "_prisma_migrations" SET checksum = '<hash>' WHERE migration_name = '<name>';を実行する
prisma migrate reset(全データ消去)は最終手段。開発 DB でもデータが残せる場合は上記手順を優先すること。
-
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(); }で同様の効果が得られる。
- スクリプトファイルのトップレベルで
-
サードパーティ CDN 画像は
<Image unoptimized>で表示する(#805)- TikTok のサムネイルは
p16-sign.tiktokcdn.comなど多様かつ動的なドメインから配信される。next.config.tsのremotePatternsにすべてのドメインを列挙するのは現実的でない。 <Image unoptimized>を使うと Next.js の画像最適化プロキシをバイパスし、URL をそのまま<img src>に渡せる。CORP ヘッダーによるブロックは発生しない(最適化プロキシ経由でないため)。referrerPolicy="no-referrer"も合わせて指定し、リファラーによるアクセス制限を回避すること。- 画像最適化(WebP変換・リサイズ)は行われないため、パフォーマンス上のトレードオフがある。固定ドメインが確定しているサービス(Instagram・YouTube 等)は引き続き
remotePatternsを使うこと。
- TikTok のサムネイルは
-
"use client"ファイルへの import 追加時はサーバー専用モジュールの混入を確認すること(#913)"use client"コンポーネントが、トップレベルでprisma・fs・next/headers等のサーバー専用モジュールを import しているファイルを(間接的にでも)import すると、クライアントバンドルにサーバーコードが混入しDATABASE_URL等のランタイムエラーが発生する。- この種のエラーは
tsc --noEmit・vitest・eslintでは検出されず、ブラウザでページを開いて初めて発覚する。 - 実装時チェック:
"use client"ファイルに新しい import を追加するときは、import 先ファイルのトップレベル import を1段確認し、サーバー専用モジュールが含まれていないかチェックすること。 - 設計原則: 純粋関数・型変換など「どこでも使える」コードは、DB/サーバー処理を含むファイルに混在させず
*-utils.tsのような独立ファイルに切り出すこと。 - 参考:
lib/tiktok-utils.ts(isTikTokThumbnailValidをlib/tiktok.tsから分離した例)
-
Neon/Vercel Blob の月間転送量削減:
unstable_cacheとnext: { revalidate }の使い分け(#944 #945)- Prisma クエリ(Neon)は
unstable_cache(next/cache)でラップし、TTL を設定することでリクエストをまたいだキャッシュが有効になる。fetch ベースの関数には適用不可。 fetchベースの関数(Vercel Blob)は{ cache: 'no-store' }を{ next: { revalidate: N } }に変更するだけで Next.js の Data Cache が効く。unstable_cacheのラップは不要。unstable_cacheは Next.js サーバーコンテキスト外では使用不可(#1002): Vitest 環境・Bun スクリプト(GitHub Actions)のいずれでもInvariant: incrementalCache missing in unstable_cacheエラーが発生する。- Vitest:
test/setup.tsにvi.mock('next/cache', () => ({ unstable_cache: (fn) => fn }))を追加して回避する。 - Bun スクリプト:
unstable_cacheでラップされた関数(例:getReleases)をscripts/から呼ぶと毎回エラーになり、try/catchのフォールバックで空データを返す。スクリプトからはunstable_cacheを使わない 生の関数(例:fetchReleasesFromDB)を別途 export して使うこと。
- Vitest:
force-dynamicページでもunstable_cache/next: { revalidate }のデータキャッシュは独立して機能するため、ページ全体の動的レンダリングとデータのキャッシュは排他ではない。
- Prisma クエリ(Neon)は
-
Instagram サーバーサイド CDN チェックは廃止・クライアント側 onError に移行(#875)
Instagram CDN はボット判定で並列リクエストをブロックしやすい。filterInaccessiblePostsで直列にサムネイル URL を検証していた(#951)。- #875 で方針転換: サーバーサイドでの Instagram CDN アクセスチェックは信頼性が低く SSR パフォーマンスの妨げになるため、Next.js アプリ側では行わない。
filterInaccessiblePostsをlib/instagram-utils.tsごと廃止。 - 正しいパターン:
thumbnailUrlが null の投稿はサーバー側でフィルタ(アクセス可否チェックは不要)。画像読み込み失敗時は<Image>のonErrorでクライアント側フォールバック UI(「画像を表示できません」)を表示する(InstagramCoverCard参照)。 - スクリプト側:
collect-instagram-posts.tsでの Blob 書き込み前サムネイル検証は引き続き有効。cache: 'no-store'は Bun 環境で無効(silently ignored)なので削除すること。
-
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_KEYSecret を共有できる。 - 詳細調査:
docs/investigations/issue-918-x-onair-data-collection.md
-
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
-
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*🔔(.+?)[/\/]等)も含めてパターンを組むことで誤マッチを防ぐ。
-
Next.js 16 の
revalidateTagは第2引数必須・呼び出し元によって使い分ける(#1000 #1065)- Next.js 16 で
next/cacheのrevalidateTagのシグネチャがrevalidateTag(tag: string, profile: string | CacheLifeConfig)に変更され、TypeScript 上は第2引数が必須になった(ランタイムは省略可能だが deprecation warning が出る)。 - Server Action から呼ぶ場合: 代わりに
updateTag(tag: string)を使うこと。引数1個のみで型エラーなく使用できる。// Server Action 内 import { updateTag } from 'next/cache'; updateTag('releases'); - Route Handler から呼ぶ場合:
updateTagは Server Action 専用のため使用不可(ランタイムエラー)。revalidateTag(tag, 'max')を使うこと。// Route Handler 内(app/api/*/route.ts) import { revalidateTag } from 'next/cache'; revalidateTag('releases', 'max'); // 第2引数 'max' で deprecation warning を抑制 unstable_cacheにtags: ['releases']オプションを付与した上でいずれかを呼ぶことで、キャッシュを即時無効化できる。- テストでは
vi.mock('next/cache', () => ({ revalidateTag: vi.fn(), updateTag: vi.fn() }))でモックする。
- Next.js 16 で
-
vitest の
TZ: 'Asia/Tokyo'設定が UTC 本番環境のタイムゾーンバグを隠す(#1041)vitest.config.tsにenv: { TZ: 'Asia/Tokyo' }を設定するとテストは JST で動作する。そのためnew Date('2026-05-30T00:00:00+09:00')のgetDate()はテスト環境では 30(JST)を返し正しく見える。- しかし GitHub Actions(UTC 環境)では
getDate()が UTC で評価されるため 29(前日)を返し、episodeIdが 1 日ずれるバグが発生する。 - 対策:
T00:00:00+09:00のような JST midnight リテラルの代わりにsetHours(0, 0, 0, 0)やnew Date(year, month - 1, day)を使ってローカル midnight を生成すると、テスト(JST)と本番(UTC)の両方でgetDate()が期待通りの日付を返す。 - 同一プロジェクト内で複数の「midnight Date 生成パターン」が混在している場合は特に注意が必要。例:
nearestSaturdayがsetHours(0, 0, 0, 0)を使う一方で、別の関数がT00:00:00+09:00を使うとformatEpisodeIdの結果が食い違う。 - 根本方針: ローカル時刻メソッド(
getDate()/getDay())を使って日付を読む関数は、同じくローカル midnight(setHours(0, 0, 0, 0))で生成された Date を渡すこと。UTC 固定や JST 固定のリテラルを混ぜない。
-
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 を持つため重複しない)。
-
prisma.track.upsertのupdate句にreleaseIdを含めないと、コンピレーション先行登録トラックがシングルに移行しない(#1061)saveDiscographyToNeonのtrackMapは format 優先度(single=1 > album=2 > compilation=4)でトラックの帰属リリースを決定するが、prisma.track.upsertのupdate句にreleaseIdを含めていなかったため、コンピレーション等で先に登録されたトラックが UPDATE パスでreleaseIdを更新されず、シングル配下に移行しなかった。- 結果として
REFETCH_ALL_SINGLES=1を実行してもシングルに紐づくべきトラックがtracksテーブルのシングルreleaseIdで見つからない状態になっていた。 - 対策:
update句にreleaseIdを追加し、format 優先度で選ばれた勝者リリースにreleaseIdを移動させる。
-
Prisma
updateのdataにundefinedを渡すとそのフィールドは更新されない(#1070)- Prisma の
update呼び出しでdataオブジェクトのフィールドがundefinedの場合、Prisma はそのフィールドを SQL UPDATE 文に含めない(nullを明示した場合は NULL に更新される)。 - この挙動を利用すると、「省略時は既存値を保持・明示 null では NULL に更新」というパターンを以下の式で実現できる:
imageUrl: data.imageUrl !== undefined ? data.imageUrl : existing.imageUrl data.imageUrl ?? nullでは undefined と null を区別できず、どちらも NULL に更新してしまう点に注意。
- Prisma の
-
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 がクエリ結果をパースする際にダブルクォートを除いた名前でオブジェクトキーを返す)。
- Prisma は
-
scripts/の import 漏れは type-check で検出されず、try/catch 内では実行時も無音で失敗する(#1114)
scripts/ディレクトリはtsconfig.jsonのexcludeに入っているため、bun run type-checkは import 漏れ(未定義識別子の参照)を検出しない。- Bun 実行時には
ReferenceErrorがスローされるが、該当コードが try/catch 内にあるとエラーが握りつぶされ、フォールバック値(空配列等)が返り続ける無音の機能不全になる。 - 実例:
collect-instagram-posts.tsのfetchExistingDailyPostsがDailyNewPostsSchemaを import せずに参照しており、catch で常に[]を返すため同日複数回実行時のデイリー投稿マージが機能していなかった。 - 対策: スクリプト内の Blob 読み込み関数等は
exportして単体テストを書くこと(テストが import 漏れをReferenceErrorとして顕在化させる)。catch で握りつぶす関数は特にテスト必須。
- 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に含めないことで全件対象になる。
- 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週)等の境界ケースをテストすること。
- 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のように安全にフォールバックできる。
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 に適用される。
- React Flow Parent Node によるグルーピング表示の実装パターン(#1211)
- React Flow でノードをグループ化表示するには Parent Node 機能を使う。グループノード(
type: 'mapGroup'等)を定義し、子ノードにparentIdを指定することで視覚的な包含関係を表現できる。 - ノード配列の並び順: グループノードを子ノードより前に配置しないと React Flow が警告を出す。
[...groupNodes, ...childNodes]の順でsetNodesに渡すこと。 expandParent: false: 子ノードにexpandParent: falseを設定しないと、子ノードの座標がグループ境界外にある場合に親ノードが自動拡張されてレイアウトが崩れる。- 子ノードの座標は親相対:
parentIdを持つノードのpositionはグループノード左上からの相対座標になる。絶対座標から変換するにはrelX = absX - groupX、relY = absY - groupY。 - グループノードの
pointerEvents: 'none': ラベルのみ表示するグループノードはstyle: { pointerEvents: 'none' }およびselectable: false, draggable: falseを設定してインタラクションを無効化する。 - 混合型ノード配列:
useNodesState<Node<TypeA | TypeB>>にすることで、異なるデータ型を持つノードを同一 state で管理できる。MiniMap のnodeColorコールバックにもNode<TypeA | TypeB>の型注釈が必要。
- React Flow の
onNodeDragStopはネイティブ DOM イベントを受け取る(#1210)
onNodeDragStopのコールバック型は(event: MouseEvent | TouchEvent, node: Node) => void(ネイティブ DOM イベント)。React の合成イベントReact.MouseEventを型注釈するとtscで型エラーになる。- イベントを使わない場合は
_event: MouseEvent | TouchEventと明示するか、引数を省略して TypeScript に推論させること。
- クライアントコンポーネントで 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 が適切。
- 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の算出方法を変える必要はない。
- ハイライト集合内で「自明・冗長な関係」を判定する完全グラフパターン(#1265)
- フィルタ等で強調表示中のノード集合 H に対し、「H 全体に共通するため表示する意味がない関係」を判定したい場合、関係種別ごとに「H 内の全ペア(
|H| × (|H|-1) / 2通り)にその種別のエッジが存在するか(完全グラフか)」で判定すると、属性系(同期・同郷等)とペア系(師弟等)を区別せず一律に扱える。 - 属性由来の関係(例:
same-gen)は、生成時点で同一属性を持つ全ペアに機械的にエッジが張られているため、「H が同一属性を共有しているか」と「H 内で完全グラフか」は等価になる。個別に属性値を比較するロジックを別途持つ必要はない。 |H| = 2の落とし穴: ペアが1組しかないため、その1組に何らかの関係が存在すれば、種別を問わず常に「完全グラフ」と判定される。軸(フィルタ条件)と無関係な関係種別であっても、H が2人だけの場合は道連れで抑制対象になる。これは意図した仕様(型・軸による特別扱いをしない一律ルール)であり、バグではない。3人以上の H で初めて「一部のペアにしかない関係は表示維持される」という判定の効果が現れる。- 実装は
lib/member-map-utils.tsのgetCommonRelationshipTypes(ペアキーを[a, b].sort().join('--')で正規化し、種別ごとにSetへ積み上げて必要数と比較)を参照。
- 並列エッジのオフセットは始点・終点ではなく中間の制御点だけをずらす(#1272)
- 同一ノードペア間に複数のエッジがある場合に重なりを防ぐため横にずらす実装で、始点(sourceX/Y)と終点(targetX/Y)の両方に同じベクトルを加算すると、線全体がノードのハンドル位置から平行移動してしまい、始点・終点どちらもノードに接続していないように見える(「宙に浮いた」エッジになる)。
- 同一ペア間に関係種別が1つしかない間は目立たないが、同一ペアに複数の関係種別(同期・同郷・同在籍等)が存在する場合にのみ発生するため、テストデータや目視確認で見落としやすい。加えて、他のエッジの陰に隠れて気づかれにくいこともある(#1265 でノイズとなる関係を非表示にした結果、この不具合を持つエッジだけが単独で目立つようになり発覚した)。
- 正しい対処: 始点・終点は実際のハンドル位置に固定したまま、2次ベジェ曲線の制御点(始点と終点の中点 + 垂直オフセット)だけをずらす。
M source Q (中点+オフセット) targetの形にすることで、両端は必ずノードに接続されたまま曲線だけが横に膨らむ。 - 実装は
lib/member-map-utils.tsのgetParallelEdgeControlPointとcomponents/GradientEdge.tsxを参照。オフセットが 0 の場合(並列エッジがない、またはインデックスが中央)は従来通りgetBezierPathを使う。
- 共演エッジの
label表示は集計側(DB)とフロント側の2箇所を両方更新しないと反映されない(#1263)
scripts/workflow/sync-relationships.tsの集計クエリでmember_relationships.labelに値を格納しても、lib/member-map-utils.tsのgetEdgeLabel()が参照するCOAPPEARANCE_NAME_TYPES(Set)にそのtypeを追加していない場合、画面には反映されず常にRELATIONSHIP_LABEL_MAPの固定文言が表示され続ける。radio-coappearance/event-coappearanceは既にCOAPPEARANCE_NAME_TYPESに含まれていたが、live-coappearanceは含まれておらず、DB側だけlabelを算出する変更をしても無効化されたままになるところだった。新しい共演系エッジ種別を追加・変更する際は、集計クエリ(バックエンド)とgetEdgeLabelの対象セット(フロントエンド)の両方を確認すること。
member_relationships.labelを type ごとに意味転用する際はgetMemberGroupKeyのグルーピングキーへの影響も確認する(#1269)
labelはsame-unit(ユニット名)・共演系(最多共演名)など type ごとに異なる意味で再利用されてきたが、mentor-studentに direct/indirect の区別を持たせる際、getMemberGroupKey(lib/member-map-utils.ts)の relationship 軸グルーピングがmatchingEdge.label ?? selectedRelTypeをグループキーとして使っていたため、そのままでは「師弟」1グループがdirect/indirectの2グループに分裂し、RELATIONSHIP_LABEL_MAPにないラベルがそのまま英語表示されてしまうところだった。- 対処:
mentor-studentはグルーピングキーとして常にselectedRelType(type 固定)を返すよう特別扱いし、labelの意味転用がグルーピング表示に波及しないようにした。 - 教訓: 既存 type の
labelに新しい意味を持たせる変更をする際は、getEdgeLabel(エッジ表示)だけでなくgetMemberGroupKey(グルーピングキー)・getFilteredMemberIds/getFilteredEdgeIds(フィルタリング、こちらはtype参照のため影響なし)など、labelを参照する全箇所を洗い出すこと。
- 既存のグラデーションエッジに「別軸の表示モード」を追加する場合は source/target カラーを同一値にする(#1270)
GradientEdge(components/GradientEdge.tsx)はsourceColor→targetColorのlinearGradientでエッジを描画する。関係性スコアなど「メンバーカラーとは無関係な指標」を色で表現したい場合、SVG 構造(<defs><linearGradient>)はそのまま流用し、sourceColorとtargetColorに同じ値(スコアに応じた単色)を渡すだけで見た目上は単色エッジになる。エッジコンポーネントの分岐を増やさずに済む。- 太さも同様に、既存の
getEdgeStrokeWidth(weight)はスコア(0〜100pt)にもそのまま適用できる(weightとtotalScoreは共に「大きいほど太くする」正の数値という同じ意味役割のため、変換式を共有できる)。 - 表示モードの切り替えは既存の filter/group モードの state 分岐に混ぜ込まない:
member-map/page.tsxはmode(filter/group)×axisの組み合わせでnodes/edgesstate を書き換える複雑なuseEffectを持つが、スコアリングモードは「現在のedgesに対して太さ・色だけを上書きする」独立したuseMemo(displayEdges)として実装し、<ReactFlow edges={displayEdges}>に渡す。既存モードのどの分岐がedgesを作っても後段で一律に上書きできるため、モード分岐を1つも触らずに新しい表示軸を追加できた。 - スコア計算関数(
getRelationshipScore)は「メンバー2人+関係性リスト+都道府県コード」を受け取る純粋関数として実装済み(#1269)だったため、事前計算してDBに保存する batch/sync は不要で、画面表示時にその場で計算するだけで済んだ。バッチで事前計算するかその場で計算するかは、値が「表示都度変わりうるか」(例: 在籍重なりはnowに依存し日々変わる)で判断するとよい。
ControlAxis(lib/member-map-utils.ts)に新しい軸を追加する際に触れるべき関数一覧(#1266)
- 軸を1つ追加すると、以下すべてに分岐を足す必要がある。1つでも漏らすと「軸ボタンは出るが選択肢が空」「フィルタは効くがグルーピングだけ効かない」といった中途半端な状態になる。
getAxisLabel(軸ボタンの日本語ラベル)getAxisValueLabel(値セレクタの表示ラベル)getAxisValues(値セレクタの選択肢一覧)getFilteredMemberIds(フィルタモードのハイライト対象算出)getMemberGroupKey(グルーピングモードのグループキー算出)buildGroupInfos(グループラベル・ソート順)MapControlPanelのAXES配列(軸ボタンの表示自体)
- 一方
getFilteredEdgeIdsはaxis === 'relationship'のときだけ専用分岐を持ち、それ以外の軸は「ハイライト済みメンバー2人を結ぶエッジを表示する」という汎用ロジック(highlightedMemberIdsベース)に自動的に乗るため、新規軸追加時は基本的に変更不要。 - 軸のラベルにメンバー名など動的データが必要な場合(例: リーダー名を使った「〇〇チルドレン」表示)、
getAxisValueLabelは固定のRELATIONSHIP_LABEL_MAPのような定数参照だけでは対応できない。members: Member[] = []のような省略可能引数を追加して後方互換を保ちつつ、呼び出し元のMapControlPanelにもmembersprop を新設して橋渡しする。
- メンバー起因の追加データ(愛称等)を新設する際、ローカルJSONの静的importで済ませてはいけない(#1266)
- 「デプロイなしでコンテンツを更新したい」データ(メンバーカラー・リーダー世代番号・教育係等)は、このアプリでは「ローカルJSON(
data/*.json、パッチスクリプトへの入力) → パッチスクリプトが Blob のmembers.jsonにマージ → ランタイムは/api/members経由でBlobから取得」という一貫したパターンを取っている(scripts/patch/patch-leader-generations.ts・patch-instructor.ts等)。 - リーダーの愛称機能を実装した際、当初
lib/member-map-utils.tsからdata/leader-nicknames.jsonを直接import(resolveJsonModule利用)する実装にしてしまった。これは静的importのためNext.jsのビルド時にバンドルされ、members.json(Blob経由でランタイム取得)と違ってJSONを編集しただけでは本番に反映されず、コミット+デプロイが必要になる。既存の「コンテンツはBlob、コードはデプロイ」という設計原則に反していた。 - 対処:
types/member.tsのMemberスキーマにleaderNickname: z.string().optional()を追加し、scripts/patch/patch-leader-nicknames.ts(patch-leader-generations.tsと同型)でBlobにマージする方式に変更。lib/member-map-utils.ts側は静的importをやめ、member.leaderNicknameを直接参照するだけになった。 - 教訓: 「メンバーに紐づく、頻繁に変わりうる/ユーザーが手動で用意するデータ」を追加する際は、真っ先に「Blobに置くべきか」を検討する。ローカル
data/*.jsonを作る前に、既存のscripts/patch/配下に類似パターンがないか確認すること。
- 標準競技順位方式(同率は同順位、次順位はタイ人数分スキップ)の実装パターン(#1282)
- 「上位N位を全員表示する」機能(同率タイがいる場合は同順位のメンバー全員を含める)を実装する際、降順ソート後に「直前の値と異なる場合のみ
rank = index + 1を更新し、同値ならそのまま前のrankを引き継ぐ」というループで標準競技順位(1224方式)を算出できる。lib/member-map-utils.tsのbuildMemberRelationshipRankingを参照。 - タイの並び順は
getScoreColor等と同様、値が同じ場合のタイブレークキーを明示することで再実行しても順序が変わらないようにした(既存の同数タイブレーク方針と同じ考え方)。当初はmemberIdの文字列順だったが、意味のある表示順ではなかったため #1291 でグループ優先順位・加入日/卒業日ベースの基準に置き換えた(76番を参照)。 - TypeScriptの余剰プロパティチェックの回避パターン:
RelationshipScore(total持ち)とMemberGroupRadarScore(pairCount持ち)のように、共通の4軸フィールドだけを受け取る型(RadarAxisScores)を関数の引数型にすると、両方の型を構造的に受け入れられて実装の重複を避けられる。ただしテストでオブジェクトリテラルを直接渡すと「余剰プロパティチェック」に引っかかり型エラーになるため、一度変数に代入してから渡すことでチェックを回避する(lib/member-map-utils.test.tsのtoNormalizedRadarValuesテストを参照)。
getRelationshipScoreが絡む「総合スコアは同値だが日付が異なる」テストケースはOVERLAP_MONTHS_CAP(10年)を超える差にして overlap 軸を両方 40pt に飽和させる(#1291)
buildMemberRelationshipRankingのタイブレーク順(グループ優先度→joinDate/gradDate)をテストする際、素朴にjoinDate/gradDateだけを変えるとgetOverlapScore(在籍期間の重複月数ベース)まで変化し、総合スコアがタイにならず意図したテストにならない。- 対策: 基準メンバーとの在籍重複期間が
OVERLAP_MONTHS_CAP(lib/member-map-utils.ts内、120ヶ月=10年)を超えるように日付を離しておけば、overlap 軸は両方とも上限の40ptに飽和するため、joinDate/gradDateの値だけを自由に変えつつ総合スコアのタイを再現できる。color・birthplace は同一値にして他の2軸も揃える。
- 複数テーブルへの CSV 一括登録は「計画を組み立てる純粋関数」と「DB I/O」を分離し、書き込み前に全件解決できるか検証する(#1305)
scripts/console/register-tour-lives.tsのように、CSV から複数テーブル(venues・tours・lives)へ新規採番しながら登録するスクリプトは、DB アクセスを含まないbuildRegistrationPlan()のような純粋関数に採番ロジックを切り出すと、実DBなしで採番パターン(連番の継続・都道府県ごとの独立採番・重複スキップ)を網羅的にテストできる。- 会場名の突き合わせのように「一部のデータが解決できないと後続の登録ができない」ケースは、書き込み(
$transaction)の前に全件を解決を試み、1件でも解決できなければ何も書き込まずにエラーで停止する(未解決名を列挙)。部分的に書き込んでしまうと再実行時の整合性判断が難しくなるため。
- メンバー単位でSNS収集するスクリプトを新設・改修する際は
shouldSyncOG()の適用漏れがないか個別に確認すること(#1297)
OG_SYNC_WEEKDAYS(scripts/lib/og-sync.tsのshouldSyncOG())によるOGメンバーの収集頻度制限は、対象スクリプトごとに個別実装されており、共通の実行エントリポイントで一括適用される仕組みではない。sync-status.ts(ブログ・Web検索)とcollect-youtube-og.ts(YouTube OG)には実装されていたが、collect-instagram-posts.ts(Instagram)には適用されておらず、OGメンバーのInstagramが毎日収集され続けるバグになっていた。- メンバー単位でSNSアカウントを走査する新規収集スクリプトを追加する際は、既存の類似スクリプト(
sync-status.ts等)を機械的にコピーするのではなく、「Active専用に処理を絞ってよいか」「OGも対象ならshouldSyncOG()で絞り込む必要があるか」を都度明示的に判断すること。 - フィルタ実装は
members.filter((m) => m.status === 'Active' || syncOG)のように「Active + syncOG時のみOG」という形にすると、shouldSyncOG()の判定結果(boolean)を引数として渡す純粋関数に切り出せ、曜日ロジック(Date依存)とメンバー選別ロジックを分離してテストしやすくなる。 - Prisma の配列形式
$transaction([op1, op2, ...])(コールバック形式$transaction(async (tx) => {...})ではない)はvi.fn((ops) => Promise.all(ops))で素直にモックできる。配列の各要素は呼び出し時点で作られた Prisma Promise であり、モックした各メソッド(create/upsert/createMany等)の戻り値をそのままPromise.allに渡せば良い。
- 正規表現で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にはタイトルのみを格納する設計に変更した。本文はグループブログの投稿者判定(メンバー名が含まれるか)にのみ使用し、出力には一切含めない。
- グループブログの投稿者判定は「本文にメンバー名が含まれるか」ではなく「タイトルに含まれるか」で行うこと(#1328)
findAllNewMemberAmebaItems/findMemberAmebaItemは、タイトル一致がなければ本文(description)一致にフォールバックしていたが、これは個人ブログ(1人専用)を前提にした設計だった。- グループブログ(複数メンバー共有)では、あるメンバーが自分の投稿内で別メンバーに言及する(例: 「バースデーイベントに岡村ほまれちゃんが来てくれました」)ことが頻繁にあり、本文一致だけで判定すると言及されただけの他メンバーにもその投稿が誤って帰属してしまう。実データで、2人の投稿が入れ替わって格納されるケースや、1つの投稿が4人分の履歴に重複して格納されるケースが見つかった。
- 対策: 呼び出し元(
buildAmebaCache)が既に持っているisGroupBlog(同一Ameba IDを共有するメンバーが2人以上か)をfindAllNewMemberAmebaItems/findMemberAmebaItemに渡し、グループブログの場合は本文一致によるフォールバックを行わない(タイトル一致のみ)。個人ブログは誤帰属のリスクがないため従来通り本文一致も許容する。 - 既存データの補正では、同一のAmeba投稿URLが複数メンバーの
statusHistoryに重複している箇所を検出し、タイトルに自分の名前があるメンバーの履歴にのみ残すパッチスクリプトで対応した。
- 配列から複数の要素を削除する際、複数グループにまたがって同じ配列のインデックスを事前計算していると、先に行った削除で後続のインデックスがずれて誤動作する
member.statusHistoryから「重複URLグループごとに削除対象を判定する」処理で、各グループの対象を{ member, index }(配列インデックス)として事前に収集し、グループを順番に処理しながらarray.splice(index, 1)していたところ、同じmemberが複数のグループに登場するケースで、先に処理したグループの削除によって配列が縮み、後続グループで使うindexが実際とは別の要素を指してしまうバグが発生した(実データのdry-run結果で、本来1人だけ一致するはずの判定が誤って2人一致と表示され発覚)。- 対策: インデックスではなく**削除対象の要素そのもの(オブジェクト参照)**を
Setに集めておき、全グループの判定が終わった後にarray.filter(e => !toRemove.has(e))でまとめて除外する。参照の同一性は配列の位置に依存しないため、処理順序に関わらず正しく動作する。 - この種のバグは小規模なユニットテスト(削除対象が1グループのみ)では再現せず、「同じ要素が複数グループに重複して登場する」ケースを明示的にテストして初めて検出できる。
- twitterapi.io の
has_next_page: falseは「投稿履歴の終端」を意味しない場合がある(#1331)
- X(旧Twitter)のタイムライン取得系APIには、直近3,200件付近までしか遡れないプラットフォーム側の恒久的な上限がある。twitterapi.io の
last_tweetsはこれをラップしているため、上限に到達するとhas_next_page: falseかつ 0件で返ってくることがあり、これは「アカウントの投稿がそこで尽きた」ことを意味しない。 モーニング女学院(@morning1422)の遡及取得で、終端エピソード(483時間目・2021-07-10)の直前週(2021-07-03)が丸ごと欠落しており、かつエピソード番号から逆算した放送開始時期(2012年頃)よりはるかに新しい時点で「完了」扱いになっていたことから発覚した。- 対策: この上限はコード側では回避不可(別のデータソースが必要)。
scripts/lib/twitterapi-client.tsのpageCount === 0判定・scripts/workflow/collect-onair-morning.tsのカーソルdoneフラグには、「取得上限到達」であって「履歴完了」ではない旨をコメント・ログメッセージで明示すること。
String[]フィールドが未登録(空配列)のレコードをhasSomeで絞り込むと除外される問題は、書き込みパイプラインより先に読み取り側フォールバックを検討する(#1326)
events.members(String[])にmemberLives未登録のライブがmembers: []で登録されており、hasSome: selectedMemberIdsによるメンバー絞り込みクエリではこれらが常に除外され、「新規登録した公演が年表に一切表示されない」バグになっていた。- 当初は
member_livesへ在籍期間ベースで自動書き込みするパイプラインの新設(要設計)を検討したが、eventsレコード自体は既に存在していたため、読み取り側のクエリ条件を緩めるだけで解消できることが判明し、書き込み側の設計は不要になった。 - パターン: Prisma の
whereにOR: [{ members: { hasSome: selectedMemberIds } }, { type: "live", members: { isEmpty: true } }, ...]を指定していったん該当レコードを取得し、membersが空だった行だけをアプリ側でフォールバック判定する(lib/timeline-utils.tsのresolveEventMembers/withResolvedMembers)。isEmptyフォールバックをtypeで絞らないと、無関係な種別まで巻き込んでしまう点に注意。 - 表示用データも同時に補完する: フィルタ判定だけでなく
members配列自体を導出結果に差し替えることで、EventCardのアバター表示・地図リンク表示(event.members.length > 0条件)も正しく機能するようになる。単に絞り込み条件だけ緩めると、イベントは表示されてもメンバー情報が空のカードになってしまう。 - 種別ごとに最適な導出ロジックが異なる点に注意(当初 live のみで実装し、レビューで festival・release も同根の問題と指摘されて拡張した):
live・festival: 個別の出演者データを持たない団体イベントとして扱い、開催日時点の在籍期間(joinDate〜gradDate、isMemberActiveOnDate)で判定する。release(モーニング娘。本体): 単純な在籍期間ではなく、既存のlib/member-release-linker.ts(linkMemberToReleases)が持つ「加入翌月以降で最初のシングル発売日」を実質基準日とするロジックを再利用する。加入直後の制作済みリリースへの誤紐づけを避けるための既存ロジックがあるのに、簡易な在籍期間判定で代替すると精度が落ちる。release(ユニットによるリリース): 在籍期間ではなく固定メンバー(unit.memberIds)で判定する(lib/unit-helpers.tsのgetSubunitMembersByReleaseId)。ユニットは全体の在籍期間と無関係な別ロジックのため、同じ関数に混ぜず種別で分岐する。
- 教訓: 「表示条件を緩める」対応をする際、対象を最初に気づいた種別(今回は live)だけに限定しがちだが、同じ empty-array 設計(=「個別登録の手間を省くための空配列」)を持つ他の種別がないか横展開の要否を確認すること。判定ロジックの精度が種別ごとに異なることは、対応範囲を狭める理由にはならない。
- 「ループ内はチェックあり・ループ後の単発処理はチェックなし」という非対称な重複排除ガードは見落とされやすい(#1329)
sync-status.tsのsyncMembers()は、sync 間の複数新着ブログ投稿を古い順に蓄積するループ(#1167)の中ではshouldSkipAccumulation(olderItem.content, member.statusHistory)で都度最新化された履歴と重複チェックしていたが、ループ終了後に呼ばれる「最新投稿」のaccumulateStatusだけはこのチェックがなく無条件実行だった。- ループ側が参照する新着配列(
newAmebaItems)の末尾2件が偶然にも同一 content(同一URL・同一投稿)だった場合、ループ側で1件目が蓄積された直後、ループ後の無条件呼び出しで内容の同じ2件目も蓄積されてしまい、statusHistoryに同一URL・同一日付・同一contentの隣接エントリが2件残る。トリガーは「RSSフィードが同一投稿を複数<item>で重複掲載する」「メンバー名一致フィルタが同一投稿を複数回拾う」等、外部データ側の揺れで発生しうるため、通常の単発ケースのテストでは再現しない。 - 対策: ループ内外で同じ蓄積関数(
accumulateStatus)を呼ぶ箇所は、ガード(shouldSkipAccumulation)も同じ条件・同じ最新状態(member.statusHistory、ループによる変更を反映済みのもの)を参照して対称に適用すること。「ループの中だけ気をつける」設計は、ループ外の単発呼び出しがガード漏れの温床になる。 - データ補正は既存の
scripts/patch/patch-ameba-misattributed-status.ts等と同じく、隣接エントリを走査して重複分を除去する冪等なパッチスクリプト(scripts/patch/patch-ameba-duplicate-status.ts)で対応した(#81 の教訓に従い、インデックスではなく1パスのfilter再構築で実装)。
- ローカルシード JSON を Blob 参照に置き換えると、Blob 未初期化時の「初回起動」経路が失われる(#1337)
sync-discography.tsのloadUnits()は元々data/units.json(リポジトリにコミットされた固定シード)を読んでいたが、データディレクトリ整理でgetUnitsFromBlob()(UNITS_BLOB_URLからの fetch)に切り替えた。UNITS_BLOB_URLが未設定・未初期化の環境ではgetUnitsFromBlob()が空配列を返し、extractUniqueMbids([])が空になってsyncDiscography()が「MBID を持つユニットが見つかりません」で即座に処理を中断する。ローカルシードのときは常に非空だったため、この経路は初めて顕在化した。- 本番では
UNITS_BLOB_URLが既にシークレット登録済みで Blob 側に実データがあるため実害はないが、「参照元をローカルシードから外部ストア(Blob/DB)に切り替える」変更は、外部ストアが空の状態からの初回ブートストラップ経路を必ず失うことに注意。ブートストラップが必要な場合は、シードを一度だけ流し込む別スクリプト・手順を用意すること。 - テスト側もこの変更に合わせて
vi.mock('../../lib/blob', ...)で複数 MBID を持つ固定フィクスチャを返すようにした。以前は実ファイル(data/units.json)の内容に暗黙依存していた(ファイル内容が変わるとテストの前提が崩れる、気づきにくい結合だった)。
- 同じイベントIDを複数箇所でDOM
idに使うと、コンポーネントの内部再利用箇所と衝突して重複IDになる(#1323)
- タイムライン画面で「今日の位置へ自動スクロール」を実装する際、ツアーグループの外側ラッパー(
app/timeline/page.tsxのliveGroupWrapper)にid={item.events[0].id}を付与した。 LiveTourGroupは内部でfirstGroup[0](=sorted[0])を使ってEventCardを描画しており、item.events[0]と同じイベントIDになるケースがほとんど(yearEventsは日付昇順で取得しているため)。もしEventCard側にも同じ規則でid={event.id}を付けていたら、外側ラッパーと内側のEventCardの<article>が同一のidを持つ「重複ID」状態になり、document.getElementByIdの挙動がブラウザ実装依存になる。- 対策:
EventCardのidprop はオプションにし、LiveTourGroup内部で呼び出す箇所には渡さない(ラッパー側のidだけをスクロール先アンカーにする)。単体(kind: "single")で直接描画するEventCardにのみid={item.event.id}を渡す。 - 一般化: あるコンポーネントが内部で子コンポーネントを複数回・別文脈で再利用している場合、その子コンポーネントに一律で
idを追加するのは危険。「外側のラッパーに付ける」か「本当に一意な描画箇所にだけ渡す」かを、再利用パターンを先に確認してから決めること。
-
scripts/patch/*.tsのrevalidateCache呼び出しは Blob 経由データにのみ必要。Prisma(Neon)直接読み込みのページには不要(#1292)#11(#1286/#1295)で確立した「手動パッチスクリプト実行後にrevalidateCacheを呼ぶ」パターンは、members.jsonのような Vercel Blob を Next.js の Data Cache(fetch(..., { next: { revalidate } }))経由で読むデータが対象。radio_onair_songs等 Neon の DB テーブルを直接読むページ(app/radio/episodes/[episode_id]/page.tsx・app/songs/[id]/page.tsx・app/timeline/page.tsx)は全てexport const dynamic = 'force-dynamic'でリクエストごとに Prisma を直接叩いており、Next.js のキャッシュを経由しない。そのため DB を直接更新するパッチスクリプトにrevalidateCacheを追加しても意味がなく、既存のcollect-onair-morning.ts・register-manual-radio-episodes.tsも呼んでいない。
-
「基準日を算出する絞り込み条件」と「表示するかどうかを判定する絞り込み条件」は必ず同じ集合を参照すること(#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を追加する。
-
data/inputs/manual-events.jsonのdateに日時を1文字列で詰め込むと、parseDateStringの正規表現に一致せず該当イベントがログにしか残らず静かに消える(#1381)sync-events.tsのparseDateStringはYYYY-MM-DD/YYYY-MM/YYYYの3パターンのみ対応しており、一致しない文字列はnullを返す。呼び出し元のconvertManualEvents/convertReleasesToEventsはパース失敗イベントをflatMapで除外するだけで、logger.warnの1行以外に失敗の痕跡が残らない。Eventテーブルにtimeカラムがなかった頃、時刻を持たせたい手動イベントは"date": "2026-08-20 16:55"のようにdateに時刻を直接埋め込んでいた(morning-status-app独自の運用上の誤用)。この形式は上記どの正規表現にも一致しないため、syncEvents()実行のたびに該当イベントがcreated/updatedカウントに含まれず、タイムラインに一切表示されないまま気づかれにくい状態が続いていた。- 対策:
#1381でEvent.time(DateTime? @db.Time(0))カラムとManualEventSchema.time("HH:MM"文字列、任意)を新設し、dateは常にYYYY-MM-DDのみを持つよう分離した。dateに時刻らしき文字列(空白区切りのHH:MM等)が含まれていないかは、bun run scripts/workflow/sync-events.ts実行時の[WARN] ...のパースに失敗ログで検知できるため、手動データ追加後は同スクリプトの WARN 行を確認する運用を徹底すること。
-
git mvを伴うデータディレクトリ整理(#1337)で、移動先ファイルを参照するスクリプト側のパス定数更新が1箇所だけ漏れていた(#1394)data/manual-radio-episodes.json→data/inputs/manual-radio-episodes.jsonへの移動(#1337「Dataディレクトリの整理」コミット)で、同様の入力ファイルを持つ他5スクリプト(sync-discography.ts・seed-member-relationships.ts・patch-member-nicknames.ts・patch-member-colors.ts・patch-member-joining-routes.ts)は全て参照パスがdata/inputs/...に追随して更新されていたが、register-manual-radio-episodes.tsのMANUAL_RADIO_EPISODES_FILE_PATHだけ旧パス(data/manual-radio-episodes.json)のまま取り残されていた。- 症状が気づかれにくかった理由: このスクリプトの
loadManualRadioEpisodes()は「ファイル未配置=一時データのため正常系」という設計(#1274)のため、存在しないパスを読もうとしてもlogger.warnの1行が出るだけでエラーにも異常終了にもならない。CI・型チェック・ユニットテスト(readFileSyncをモックしているため実ファイルの有無を検証できない)のいずれでも検出されず、機能そのものが約1週間サイレントに停止していた。 - 教訓:
git mv/ファイル移動を伴うリファクタリングでは、同じパターン(同一コミットで一括移動された複数ファイル)を持つ他の参照元スクリプトを横断的にgrepして、更新漏れがないか機械的に確認すること。「ファイル未配置は正常系」として握りつぶす設計のスクリプトは、パス誤りとファイル未配置の実質的な違いがログ以外に現れないため、リファクタリング時の検証観点として特に意識する必要がある。
-
Instagramプロフィールグリッド内のReel投稿リンクはユーザー名プレフィックス付きで出現する(
/{username}/reel/{id}/)。新規Playwrightスクリプトもnode実行なら Windows で直接動作する(#1373)isReelHrefはグリッド内リンクを/reel/{id}/(先頭がreel)形式と仮定していたが、実際のDOM(a[href*="/reel/"])は/{username}/reel/{id}/というユーザー名プレフィックス付きの相対パスだった。パスの先頭セグメントのみを見る判定(pathname.split('/')[0])は常にusernameを返すため、Reel投稿が1件も検出できていなかった。対策: パスの先頭に限定せず、任意セグメントにreel/reelsが含まれるかで判定する(extractPostIdのfindIndexと同じ考え方に統一)。Instagramのグリッド内リンクを扱う実装(新規追加時含む)は、この形式差を前提にすること。node実行によるWindows制約の回避範囲の拡張: テスト手法#24(Windows + Bun でchromium.launch()がハング、nodeなら動作する)は「手動検証用の.mjsスクリプト」の文脈で記録されていたが、Node 24(node --versionで確認)は.tsファイルもビルド不要でそのまま実行できる(型ストリッピング内蔵)ため、scripts/patch/*.tsのような実際のPlaywright依存パッチスクリプトもnodeで実行すればWindows上で動作する。以前は「Playwrightが必要な処理はmacOS必須」と誤って結論づけたが、bunではなくnodeで実行する前提を検証してから制約を判断すること。訂正(#1400、詳細は本書#93): ここでの検証は relative import を持たない単純なスクリプトのみで確認したもので、patch-instagram-reel-flag.tsのように他モジュールを import するスクリプトではnode scripts/patch/xxx.tsの直接実行だけでは不十分(拡張子なしimportが解決できない)と判明した。- 教訓: Playwrightスクリプトが特定OSで動かないという既存の知見に当たった場合、まず自分でその制約を再現・再検証してから対応方針を決める(過去の知見が別のランタイム(Bun vs Node)を暗黙の前提にしていることがあるため、書かれている条件を字面通りに一般化しない)。
-
外部サービスを操作するCLIの引数は、そのサービス自身の識別子(汎用)を受け付け、アプリ内部の管理用ID(局所的)は内部で解決する(#1400)
patch-instagram-reel-flag.ts(#1373)の初期実装は--idにメンバーの内部ID・公式アカウントの内部ID(ハイフン区切り、morningmusume-official等)を要求し、Blob格納パス(instagram/{id}/posts.json)にもそのまま使っていた。しかし操作者が実際に把握しているのはInstagramの実アカウント名(アンダースコア区切り、morningmusume_official等)であり、表記差異により--id morningmusume_officialを渡すと「見つかりません」エラーで失敗した。- 原因の本質: 「操作者がドキュメントを読んでいない/内部IDを知らない」という認知面の問題ではなく、識別子の汎用性の扱いが逆転している設計原則の問題。Instagramアカウント名はアプリ外部(Instagram自体)に存在し誰でも参照・検証できる汎用的な識別子であり、内部ID(メンバーID・公式アカウントID)はこのアプリのBlob格納パスにしか意味を持たない局所的な識別子。外部システムを操作するインターフェースは前者を入力として受け取り、後者への変換は実装側で隠蔽すべきで、逆転させると「内部の管理用キーを知らないと外部システムを操作できない」構造になり、ドキュメントの正確性とは無関係に誤操作を誘発し続ける。
- 対策:
resolveUsernameを「内部IDから検索」ではなく「Instagramアカウント名からofficialSns/OFFICIAL_INSTAGRAM_ACCOUNTSのinstagramUrlを逆引き」する設計に変更し、Blob格納用の内部ID(storageId)は逆引き結果から解決するようにした。また、officialSnsに該当URLはあるがactive: false(登録済みだが収集対象外)のケースを、完全な未登録(not-found)と区別するinactive状態も設けた(該当なしとだけ表示すると事実と異なるため)。 - 教訓: CLIツールが「内部データモデルのID」と「外部サービス自身の識別子」の両方を扱う場合、引数として要求すべきは常に外部サービス側の識別子。内部IDを引数に要求する設計は、実装者にとっての実装しやすさ(DBキーとの直接一致)を操作者の使いやすさより優先してしまっている兆候であり、レビュー時に「この引数の値を、操作者は他の場所(このアプリ以外)で確認できるか」を確認する観点として持つ。
-
patch-instagram-reel-flag.tsを実際にnode実行すると、項目91で「動作する」とした前提が崩れ3つの問題が連鎖して起動すらできなかった(#1400)- 問題1: 拡張子なし相対importが解決できない:
tsconfig.jsonのmoduleResolution: "bundler"を前提にfrom '../../types/instagram'のような拡張子なしimportを使っているが、プレーンなnodeのESMローダーはこれを解決できずERR_MODULE_NOT_FOUNDで起動時に落ちる。項目91の検証はrelative importを持たない単純なスクリプトのみで行われており、複数モジュールを跨ぐ実際のパッチスクリプトでは再現しなかった。 - 対策1:
tsx(devDependencies に追加)をnode --import tsx scripts/patch/xxx.tsの形でローダーとして噛ませることで、ソースコードのimport文を一切変更せずに拡張子省略解決を行わせる。プロセス起動自体はnodeバイナリが行うため、項目24のBunハング問題も回避できる。 - 問題2:
.env.localが自動読み込みされない:bunは.env.localを自動読み込みするが、nodeは読み込まないためBLOB_READ_WRITE_TOKEN等が全て未設定のまま実行される(エラーメッセージで気づける)。 - 対策2:
node --env-file=.env.localを付与する(Node 20.6+で利用可能)。 - 問題3:
import.meta.mainがtsxローダー経由ではundefinedになる: Bun・ネイティブnode script.tsではエントリポイント判定として機能するが、node --import tsx script.ts経由だとローダーがエントリポイント情報を伝播せずundefinedになり、if (import.meta.main)のガードが常に false 扱いになってメイン処理が黙って実行されない(エラーも出ず exit 0 で終了するため気づきにくい)。 - 対策3:
process.argv[1] === fileURLToPath(import.meta.url)という移植性のある判定に置き換える。bun・ネイティブnode・node --import tsxの3パターン全てで正しく動作することを確認済み。 - 教訓: 「
nodeで動く」という検証は、実行方法(プレーンnode / ローダー経由)・環境変数読み込み・エントリポイント判定という複数のレイヤーそれぞれで成立を確認しないと、一部レイヤーだけ検証して「動作する」と一般化してしまう。特にBun向けに書かれたコード(import.meta.main等のBun-optimized API)をNodeで動かす場合は、そのAPIがローダー経由でも同じ挙動を保証するかまで確認すること。
- 問題1: 拡張子なし相対importが解決できない:
-
同じ対象データに対する判定ロジックが複数箇所に分散していないか、新規実装前に確認する(#1403)
- デイリーダイジェスト集計用の
buildTikTokSummaryEntry(scripts/workflow/collect-tiktok-posts.ts)は、UI表示側で使われている正しいメンバー判定関数attributeTikTokPostToMembers(lib/tiktok-attribution.ts、キャプション内の複数メンバー名をすべて拾う実装)とは別に、caption.includes(member.name)で最初にマッチしたメンバーのみを採用しbreakする簡易な自前マッチを独自に実装していた。1投稿のキャプションに複数メンバー名が含まれる場合、2人目以降が新着通知の集計から漏れるバグになっていた。 - 対策:
buildTikTokSummaryEntryをattributeTikTokPostToMembersの呼び出しに置き換え、判定ロジックを一本化した。 - 教訓: 集計・表示用の新しいロジックを書く前に、同じ対象データ(この場合はTikTok投稿キャプションのメンバー名マッチング)に対する既存の判定関数がないか確認すること。片方だけ改修すると仕様がズレたまま気づかれにくい(実装#9「SNS判定は判定ロジックを
lib/sns.tsに集約する」と同種の原則)。
- デイリーダイジェスト集計用の
-
「新着」判定は投稿日時(イベント発生日時)ではなく収集日時(バッチが実際に取得した日時)を基準にする(#1408)
- Ameba(
components/NewPostsSection.tsxのAmebaウィンドウ判定)・TikTok(同ファイルのrecentTikTokDate)・YouTube(lib/youtube.tsのgetRecentYoutubePosts)の3セクションが、それぞれ独立した実装であるにもかかわらず同じ誤りを持っていた。投稿自身の日時(RSSのpubDate・TikTokのcreatedAt・YouTubeのpublishedAt)を新着ウィンドウ・当日判定の基準にしていたため、RSS反映やAPI取得の遅延で「投稿されたのは前日以前だが収集は当日」というケースが判定から漏れていた。 - 対策: 3セクションとも、投稿日時ではなく収集日時(
capturedAt、Ameba用はStatusEntryに新規追加)を基準に変更した。Instagram(scripts/workflow/collect-instagram-posts.tsの当日マージ)は元々capturedAt基準だったため対象外。 - 教訓: 「直近の新着」を提示する機能を新規実装する際、対象データが「発生した日時」と「取得された日時」の2種類の日時を持つ場合、判定基準に使うべきは常に後者(収集日時)。前者を使うと、収集タイミングの揺らぎ・API/RSS反映遅延によって「収集はしたのに表示されない」不整合が必ず発生する。同種の新着表示機能を追加する際は、まずこの2種類の日時が対象データに存在するかを確認すること。
- 実装時の副次的な罠: TikTokの投稿一覧(
allTikTokPosts)は投稿日時(createdAt)降順でソートされていたため、「配列の[0]が収集日時(capturedAt)の最大値である」という前提が成立しなかった(別フィールドでソートされた配列の[0]を、そのフィールドとは異なるフィールドの最大値として扱わないこと)。reduceで全件からcapturedAtの最大値を求めるよう修正した。
- Ameba(
-
過去エントリへの
capturedAtバックフィルは「スクリプト実行時刻」ではなく「実際に収集が完了した時刻」を使う(#1414)- #95(
capturedAt導入)のマージ前に蓄積された Ameba 投稿へcapturedAtを手動設定するパッチスクリプトbackfill-ameba-captured-at.tsの初版は、new Date().toISOString()(スクリプト実行時刻)をcapturedAtとして設定していた。新着ウィンドウは[previousAutoSyncedAt, lastSyncedAt](ameba/sync-status.json)で、lastSyncedAtは「最後に定期実行が完了した時刻」を指す。スクリプトの実行タイミングが定期実行より後になると、設定したcapturedAtがウィンドウの終端より後ろになり、対象エントリが新着表示から除外される。 - 対策:
capturedAtには、実際にその投稿を収集した定期実行(GitHub Actions run)の完了時刻(=当時のlastSyncedAt書き出し値)を固定値として設定するよう修正した。ウィンドウ側(ameba/sync-status.json)は一切変更しない。 - 教訓: 「収集日時」フィールドを過去データへ事後的にバックフィルする場合、その値は「パッチスクリプトを実行した日時」ではなく「対象データが実際に収集された(=当時のバッチが完了した)日時」でなければならない。前者を使うと、新着ウィンドウのような「直近の実行区間」を前提にした判定から漏れる。
- 対象URLのハードコードは実際のワークフロー実行ログ全体で洗い出す: 初版は Issue 本文に記載された現役メンバー分のURLのみを対象にしており、同じ実行内で蓄積されたOG(卒業生)メンバー分の投稿(個人ブログ)が漏れていた。特定の1回の実行が対象の一次資料になる場合は、
gh run view <run-id> --logで該当実行のログ全体を確認し、部分的な抜粋(Issue本文のログ転記等)だけで対象を確定しないこと。 - 冪等性は「対象値に収束するか」で定義する: 当初は「既に
capturedAtが設定済みなら上書きしない」という冪等性だったため、誤った値(スクリプト実行時刻)で一度実行してしまうと、後から正しい値に修正しても再実行で補正できなかった。一度限りの補正用パッチスクリプトでは、「既に設定したい値と同じなら上書きしない・異なれば上書きする」という定義にしておくと、誤実行後の再実行でも正しい値に収束する。 scripts/patch/*.tsのrevalidateCache呼び出し漏れ(実装#11 の再発): このスクリプトの初版は Blob 書き戻し後にrevalidateCacheを呼んでいなかった。手動実行パッチスクリプトを新規作成する際は、既存のpatch-member-nicknames.ts等と同じくAPP_URL/REVALIDATE_TOKENを読み込み、put()後にrevalidateCacheを呼ぶ雛形に揃えること。- 「対象URLが存在しない」を即座に対象外と判断せず、なぜ存在しないかを確認する: ハードコードした対象URLのうち1件(高橋愛の投稿)は dry-run で「変更0件」となったため、当初は「実際には蓄積されなかった投稿」として対象から除外した。しかし実際のブログページを直接確認すると、2026-07-24付の実在する投稿だった。原因はタイトル重複判定バグ(
shouldSkipAccumulation、#1417で追跡)で、収集自体が一度も行われていなかった。過去データのバックフィル対象URLがstatusHistoryに見当たらない場合、「収集されなかった=対象外でよい」と決めつけず、実際の投稿ページを確認して「収集漏れ(別バグ)」の可能性を切り分けること。 後者だった場合、capturedAtの補正だけでは直せず、実際のタイトル・投稿日を確認した上でエントリ自体を新規追加する必要がある。
- #95(
-
複数の独立したスケジュールが同じ対象データを更新する「新着」判定は、固定のカレンダー日境界ではなく実行区間ウィンドウで行う(#1450)
- #95で YouTube の新着判定を「投稿日時」から「収集日時(
capturedAt)基準」に直したが、そのときの実装は依然として「全チャンネル横断で最新のcapturedAtを1件取得し、その JST 日付と同日のレコードを新着とする」という固定のカレンダー日境界方式だった。 - YouTube は
official(JST 21:30起動)・og(JST 22:00起動)という2つの独立したスケジュールが同じyoutube_postsテーブルを更新する構成のため、片方の実行が日付をまたいで遅延すると、先に収集されたもう片方の投稿だけが「前日扱い」となり新着判定から漏れた。単一スケジュールの収集(Instagram・TikTok)では顕在化しない、複数スケジュールが同じ読み取りパスを共有する場合に固有のバグである。 - 対策(初版・後にPRレビューで不十分と判明): Ameba が既に採用していた実行区間ウィンドウ方式(
[previousAutoSyncedAt, lastSyncedAt]、#1387)を YouTube にも適用した。Neon に1行のみのyoutube_sync_statusテーブル(TikTokSyncStatusと同じ単一行 upsert パターン)を追加し、official・og両方の収集スクリプトが実行完了時に同じ3カラムを共有スライドさせる、scripts/workflow/sync-status.tsの Ameba 向け実装と完全に同一のロジックにした。 - この初版が再発させた別の不具合: Ameba は現役・OGを1つのワークフロー内でまとめて処理するため単一ウィンドウの共有スライドで問題ないが、YouTubeは
official(毎日)・og(OG_SYNC_WEEKDAYS指定曜日)という独立した2つのスケジュールが同じ3カラムを共有スライドする。official実行の直後(数十分後)にog実行が完了すると、previousAutoSyncedAtがog自身の実行時刻まで押し上げられ、officialが数十秒前に収集した投稿のcapturedAtがウィンドウ下限を下回って新着から除外される。これは本Issueが修正しようとした「日付境界による見落とし」と全く同じ症状(official投稿が漏れる)が、原因を変えて再発したもの。#98参照。 - 最終対策:
youtube_sync_statusをofficial/og独立の6カラム(officialLastSyncedAt等・ogLastSyncedAt等)に分割し、updateYoutubeSyncStatus(channelType)で呼び出したチャンネルのカラムのみを更新するよう変更。getRecentYoutubePosts()もチャンネルごとに独立したウィンドウで判定する(OR条件)。詳細は「YouTube投稿一覧機能 データ設計書」§6.2参照。 - 教訓: 「新着」判定を実装・レビューする際、判定対象データを更新するバッチ・ワークフローが複数の独立したスケジュールを持つかどうかを必ず確認すること。単一スケジュールなら固定のカレンダー日境界でも実用上問題になりにくいが、複数スケジュールが近接していると、どちらかの遅延で「日付をまたいだ側だけ排除される」不具合が高確率で発生する。新着ウィンドウは常に「その機能を支える収集バッチ自身の実行区間」を基準にすること。さらに、既存の類似実装(Ameba)を「同一ロジックだから」という理由で単純に流用する際は、その実装が前提とする「更新者は単一スケジュールか、複数の独立したスケジュールか」を必ず確認すること。単一スケジュール向けの共有カーソル方式を、複数の独立したスケジューラがいるケースにそのまま適用すると、共有カーソルの押し合いによって解決したはずのバグが別の形で再発する。
- #95で YouTube の新着判定を「投稿日時」から「収集日時(
-
bunx prisma <command>はbun.lock/package.jsonのバージョンではなく、node_modulesに既に解決済みのバージョンをそのまま使う(#1450)package.jsonのprisma/@prisma/clientを^7.9.0に更新済みでも、bun installを実行せずbunx prisma generateを叩くと、node_modulesに残っていた旧バージョン(このケースでは 7.8.0)でコマンドが実行され、bunxは警告なしにそのまま完走する。「さらに新しいバージョンが存在する」ケースでは末尾に Update available の案内が出るが、「lockfile が期待するバージョンと乖離している」ケースでは何も警告されない。- 教訓: Prisma スキーマを変更してマイグレーションを作成する前は、
bun installを必ず実行してからbunx prisma generate→bunx prisma migrate dev --name <name>の順で進めること(実装#42の手順の前提条件として、bun install省略不可を明示する)。git pull直後の実装開始チェックリストと同じ理由で、依存関係が古いままだと不整合が症状化するまで気づけない。
-
OfficialSummaryEntry移行はSNS別収集スクリプト(#1455/#1456)が集約側(send-daily-digest.ts、#1457)より先にマージされる順序で分割されており、単体マージ時は集約側が一時的にクラッシュしうる(#1455)send-daily-digest.tsのbuildDigestEmailBodyはentry.source === 'youtube-official'のみを特別扱いし、それ以外のsourceは無条件でMemberSummaryEntryにキャストしてentriesを反復する。instagram-official/tiktok-official等の新しいOfficialSummaryEntryソースが書き出されると、entriesが存在せず(OfficialSummaryEntryはaccountsを持つ)TypeError: memberEntry.entries is not iterableでsend-daily-digest.tsの実行全体が失敗する。- 原因: Issue分割(#1423 親Issue → #1454 型拡張 → #1455 Instagram移行 / #1456 TikTok移行 → #1457 集約側改修、という依存関係)により、収集スクリプト側の移行と集約側の改修が別Issue・別PRになっている。#1457 は #1455・#1456 完了後に着手する設計のため、収集側だけ先にマージすると集約側の対応漏れ状態が一時的に生じる。
- 対策: #1455・#1456 をマージした後は速やかに #1457 をマージすること。並行して複数の収集スクリプトを移行する場合、それぞれのPRマージ直後に daily-digest 送信ワークフロー(GitHub Actions)が実行される前に、集約側の対応が完了しているか確認すること。
- 教訓: 収集スクリプト側と集約側のように「データを書き出す側」と「データを読む側」を別Issue・別PRに分割する場合、収集側の型・データ形状の変更が集約側に無条件でキャストされて読まれていないか(本件のように新しいソース種別だけ分岐から漏れていないか)を分割設計の時点で確認すること。分割そのものを避けるのではなく、先行Issueのマージ直後は後続Issueを最優先でレビュー・マージする運用にして、対応漏れ状態が本番ワークフローの実行タイミングと重ならないようにする。
-
メンバー名の異体字(旧字体/新字体)対応漏れは、SNS別の
attribution.ts3ファイルに同じ関数が重複しているため個別に発生しうる(#1470) - TikTokで複数メンバーがハッシュタグ指定された投稿のうち1名しか紐付かない不具合を調査したところ、原因はキャプション側の表記「桜井梨央」(新字体)とBlob上のメンバーデータの表記「櫻井梨央」(旧字体「櫻」)が一致していないことだった。lib/tiktok-attribution.tsのnormalizeForNameMatch()は﨑→崎・髙→高・栁→柳・𠮷→吉の4種の異体字正規化のみを持ち、櫻→桜が未対応だった。 - 調査手法: Blobから取得したmembers.jsonの対象メンバーのnameフィールドと、Neonに保存された投稿の実際のcaptionを突き合わせて文字単位で比較し、一致しない箇所を特定した(メンバー名は目視では新字体・旧字体の区別がつきにくいため、文字列比較で機械的に検出する必要がある)。 - 同一バグの横展開:normalizeForNameMatch()はlib/tiktok-attribution.ts・lib/youtube-attribution.ts・lib/instagram-attribution.tsの3ファイルに同一実装が重複しており、同じ原因のバグが3ファイルすべてに存在していた。共通モジュールへの切り出しはされていないため、1ファイルだけ直して他を放置すると同じ不具合が他のSNSでも再現する。 - 永続化の有無で対応が異なる: TikTok(lib/tiktok.tsのgetTikTokPosts経由、mentionedMemberIdsをNeonに永続化)は収集済みの過去データを遡及修正するワンショットパッチスクリプトが必要だが、YouTube/Instagramはapp/members/[id]/page.tsxでリクエスト時にattributeYoutubeVideoToMembers/attributeInstagramPostToMembersを計算しており非永続化のため、コード修正のみで即座に反映されバックフィル不要だった。 - 教訓: 複数のSNS収集ロジックが同じパターン(ハッシュタグ抽出→メンバー名正規化→マッチング)で実装されているコードベースでは、1箇所で見つかった正規化漏れ・表記ゆれバグは、類似実装が他に重複していないか横展開調査を必ず行うこと(grepで関数名・処理パターンを検索)。また、修正対象データがBlob経由(非永続・キャッシュのみ)かNeon直接永続化かによって、バックフィルの要否が変わる点も合わせて確認すること。 -
Amebaグループブログの投稿者判定は「タイトル末尾に最も近いメンバー名」で一意に絞り込む(#1480) - グループ共有ブログでは
findMemberAmebaItem/findAllNewMemberAmebaItems(scripts/workflow/sync-status.ts)がメンバーごとに独立して「タイトルに自分の名前が含まれるか」を.includes()で判定していたため、タイトルに複数の共有メンバー名が含まれる投稿(実データ例: 「井上春華にお土産渡してみた弓桁朱琴」)では、言及されただけのメンバーにも投稿が重複帰属していた。 - 実際のAmeba RSS(https://rssblog.ameba.jp/{blogId}/rss20.xml)をcurlで取得し全タイトルを確認したところ、例外なく投稿者本人の氏名がタイトル末尾に付記される運用だった。<category>タグ等の構造化された投稿者情報はRSSに存在しないため、この命名慣習をロジック化する以外に信頼できる判定手段がない。共有メンバー名の候補のうちタイトル内で最も末尾に近い位置(lastIndexOfが最大)に出現する名前を投稿者と判定するresolveAmebaGroupBlogAuthorNameを新設した。 - 判定ロジックの一本化(実装#94の適用例): 同じ「タイトルからメンバーを判定する」ロジックが、日次バッチ(sync-status.tsのグループブログ振り分け)と手動パッチ(scripts/patch/patch-ameba-misattributed-status.tsの重複解消)の2箇所に独立実装されていた。修正では両方がresolveAmebaGroupBlogAuthorNameを共有するよう統一し、判定ルールが片方だけ更新されて乖離する事態を防いだ。 - 既存関数への後方互換な拡張:isGroupBlogを持つ既存関数にgroupMemberNames: string[] = []をオプション引数として追加し、デフォルト(空配列)では従来どおりの単純.includes()判定に振る舞うよう設計した。呼び出し元(buildAmebaCache)が共有メンバー全員の氏名リストを渡した場合のみ、末尾優先の絞り込みが有効になる。この設計により、4箇所以上ある既存の呼び出し・テストを変更せずに新しい絞り込みロジックを追加できた。 -
ランキングモードのキーフレームは基準メンバー1名の日付のみが基点のため、加入日より前は構造的に生成されない(#1310) - レーダーチャート時系列表示のキーフレーム算出で、think-issueの検討コメントには「ランキングモードで基準メンバー加入日より前のキーフレームが除外される」というテストケースが明記されていた。素直に実装すると
keyframeDates.filter((d) => d.getTime() >= selectedJoin.getTime())のようなガード処理を書きたくなる。 - しかし設計書(member-map-radar-chart-design.md §10-2)では、ランキングモード(buildMemberRankingTimeline)のキーフレーム基点は「基準メンバー1名」の加入日・卒業日のみに限定されている。基点が基準メンバー自身の日付しかない以上、そこから補完される全キーフレームは数学的に必ず加入日以降になり、上記フィルタは呼ばれても何も除去しないデッドコードになる。 - 対応: フィルタ処理は追加せず、「基点データ自体が対象を基準メンバー1名に限定しているため、加入日より前のキーフレームは生成され得ない」という設計上の性質(invariant)を実装コメントで明記し、テストではその不変条件(返り値が全て加入日以降であること)を確認する形にとどめた。 -
既存データがあるテーブルにNOT NULLカラムを複数追加する場合、
prisma migrate devは実行を拒否する。--create-onlyでスケルトンを生成してからバックフィルを手で挿入する(#1489) -tiktok_sync_status(既存行1件)にofficial/og独立ウィンドウ用の6カラム(officialLastSyncedAt等、いずれもデフォルト値なしのNOT NULLDateTime)を追加しようとしたところ、bunx prisma migrate dev --name <name>が「Added the required columnxxx… 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の使用)を提示している。