Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

buildSignagePayloadForClassを使わない盤面プレビューは自動ブロックが実機と食い違う

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

サイネージのWYSIWYGエディタでプレビューの基底データを実機とは別に手組みすると、パターン追従の枠は正しく描けても中身が空のまま固定され、実機とプレビューの表示が食い違う。

結論

サイネージのクラスエディタで、WYSIWYGプレビューの基底データ(boardBase)を実機とは別に手組みしていた。 教員が編集する visitor(来校者一覧)や callout(生徒呼び出し)のようなブロックは正しく取得していたが、news(時事ニュース)・trainStatus(鉄道)・presenceCount(人感センサ)・weatherWarningsheatAlerts(防災・熱中症)といったシステム供給の自動ブロックは、パターンに関係なく null 固定にしていた。実機のTVは単一ソースの buildSignagePayloadForClassPATTERN_BLOCKS のゲートで全ブロックを取得するため、pattern2〜4ではエディタと実機の見た目が食い違っていた。プレビューの基底を実機と同じ buildSignagePayloadForClass から組み直すことで解消した。

症状

2026-06-20、本番反映直後に「エディタのパターンと表示パターンが合っていない」というユーザー報告が入った。クラスエディタ(/app/app/editor/[classId])を開くとWYSIWYGの盤面プレビューが表示されるが、実機のサイネージ端末と見比べると、パターンによって次のブロックが空欄になっていた。

  • pattern2: 鉄道・人感センサ・時事ニュースが空
  • pattern3: 鉄道・人感センサが空
  • pattern4: ニュース(主役ブロック)・鉄道・人感センサ・防災帯が空

pattern4は自動ブロックの比重がもっとも高い構成だったため、本番反映直後で最も目立つ形で報告につながった。エディタ側は「正しいパターンの枠」自体は描けていたので、レイアウトの崩れではなく中身だけが常に空という、原因の見当がつきにくい症状だった。

原因

エディタのプレビュー基底は、実機の取得経路とは別に手組みされていた。

// 修正前(自動ブロックを null 固定)
const boardBase: EditorBoardBase | null = previewDaily
  ? {
      date,
      designPattern: pattern,
      daily: previewDaily,
      scheduleDays: previewScheduleDays,
      ads: previewAds,
      weather: previewWeather,
      classContext: previewClassContext,
      presenceCount: null,
      visitors: showVisitors ? visitors : null,
      callouts: showCallouts ? callouts : null,
      trainStatus: null,
      // 時事ニュース(自動ブロック・ADR-043)は鉄道/センサと同じく編集プレビューでは出さない(null)。
      news: null,
      // 防災・安全(自動ブロック・ADR-044)も編集プレビューでは出さない(null=帯ごと非表示)。
      weatherWarnings: null,
      heatAlerts: null,
      blackout,
    }
  : null;

previewDailypreviewAds など、教員が編集する日次データはエディタ側で個別に取得していた。その並びに合わせて自動ブロックも1つずつフィールドとして並べる形になっており、presenceCounttrainStatusnewsweatherWarningsheatAlerts取得すらせず、コメント付きで意図的に null を渡していた。

一方、実機のTVが盤面を組み立てる buildSignagePayloadForClass は単一ソースの取得関数で、PATTERN_BLOCKS(パターンごとにどのブロックを出すかを1箇所で宣言したマッピング)のゲートに従って全ブロックを取得する。エディタのプレビューはこの関数を経由していなかったため、実機側でパターンに応じて出るはずの自動ブロックが、エディタでは常に取得されない状態になっていた。

型としては EditorBoardBase の許容範囲内で、null を渡されたコンポーネントはウィジェットを描かないだけでエラーにはならない。ビルドもテストも通る状態のまま、実機とエディタの出力を突き合わせる仕組みが無かったため、この食い違いは静かに残り続けていた。

直し方

プレビューの基底を、実機と完全に同一の buildSignagePayloadForClass の呼び出し結果から組むように変えた。

// WYSIWYG(盤面を編集タブ)のライブプレビュー基底は、実機サイネージと完全に同一の payload builder
// (buildSignagePayloadForClass)から組む。これにより自動コンテンツ系ブロック(時事ニュース / 鉄道 /
// 人感センサ / 防災・安全帯)も実機と同じ取得ゲート(PATTERN_BLOCKS)・同じ fail-soft で取得・描画され、
// エディタの盤面と実機TVの見た目が一致する。
const board = user.schoolId
  ? await buildSignagePayloadForClass(tx, user.schoolId, classId, date, pattern)
  : null;

すでに解決済みの patterndesignParam として渡しているため、buildSignagePayloadForClass の内部でパターンを二重に解決することもない。この変更に合わせて、手組みしていた重複取得(previewDaily / previewAds / previewScheduleDays / previewClassContext / previewWeather / blackout / visitors / callouts)を丸ごと撤去し、コード量はnet -55行になった。

教員が編集する visitor(来校者一覧)・callout(生徒呼び出し)は編集対象ブロックとして扱いが別で、patternIncludesBlock を使って表示可否だけを判定する形は変えていない。

const showVisitors = patternIncludesBlock(pattern, "visitor");
const showCallouts = patternIncludesBlock(pattern, "callout");

保存・検証・自動保存・RLS・監査ロジックには手を入れていない。ズレていたのはプレビューが参照するデータの取得経路だけだった。

再発防止

自動ブロックだけを個別フィールドとして手組みする実装は、フィールドを1つ増やすたびに「実機側の取得条件と同じか」を書いた本人が毎回確認する前提になっており、確認が漏れれば型チェックもテストも通り抜ける。**実機とプレビューが同じ関数を呼ぶ構成にしたことで、パターンごとの出し分けは PATTERN_BLOCKS という単一ソースにしか存在しなくなり、新しいパターンや自動ブロックを追加する際もエディタ側の分岐を個別に足す必要がなくなった。**手組みの複製をなくすこと自体が、この種のドリフトに対する再発防止になっている。

よくある質問

Q1なぜこのズレはビルドやテストで検知できなかったのですか?

boardBaseの手組みはTypeScriptの型としては正しく、presenceCountやtrainStatusにnullを渡すこと自体はEditorBoardBaseの許容範囲内だった。エディタ側のコンポーネントはnullを渡されればウィジェットを描かないだけでエラーにはならず、実機側のbuildSignagePayloadForClassとは別経路で動いていたため、両者の出力を突き合わせるテストが無ければ静かにズレたまま気づけない。

Q2全パターンで同じようにズレていたのですか?

いいえ。pattern1は自動ブロックを使わない構成だったため影響が無く、pattern2は鉄道・人感センサ・時事ニュース、pattern3は鉄道・人感センサ、pattern4はニュース(主役ブロック)・鉄道・人感センサ・防災帯が空欄になった。pattern4は自動ブロックの比重が最も高い構成だったため、本番反映直後で最も目立つ形でユーザー報告につながった。

Q3来校者一覧や生徒呼び出しのような編集対象ブロックも同じ原因で崩れていたのですか?

崩れていない。visitor/calloutは教員が日次入力する編集対象ブロックで、修正前から取得済みのスナップショットをそのまま渡していた。null固定になっていたのはnews/trainStatus/presenceCount/weatherWarnings/heatAlertsのようなシステム供給の自動ブロックだけで、両者はPATTERN_BLOCKSのマッピング上も別カテゴリとして扱われている。

Q4直し方はエディタ側に実機と同じ取得ロジックを複製したのですか?

複製していない。エディタのプレビュー基底を、実機と完全に同一のbuildSignagePayloadForClassの呼び出し結果から組むように変えた。これにより手組みしていた重複取得(previewDaily/previewAds/previewScheduleDays/previewClassContext/previewWeather/blackout/visitors/callouts)が丸ごと不要になり、コード量はnet -55行に減っている。

確認した環境

  • Next.js 16.0 / React 19.0(apps/web/package.json、2026-06-20時点)
  • 2026-06-20 に本番で発生、同日中に修正

この記事の根拠

  • TypeScriptファイル 1〜20行目コミット 7db7a5d
  • TypeScriptファイル 76〜110行目コミット 7db7a5d
  • TypeScriptファイル 161〜182行目コミット d0eff93

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。