コンテンツへスキップ

サイト一覧の 🔌 ボタンに「未更新プラグイン数」バッジを足す — 既存キャッシュを二重化せずに流用する設計

クライアントから「全サイト横断チェックをかけた後、各サイトに未更新プラグインが何個残っているかを、サイト一覧で常に見えるようにしてほしい」という要望が来ました。サイト一覧の 🔌 プラグインボタンの右上に、未読通知のような小さな赤丸数字バッジを置く ── 見せ方は明確でした。

問題は データをどこから引いてくるか。素直に「各サイトの未更新数」を新規 API + 新規キャッシュで持つこともできましたが、それをやるとステートが二重化して保守が増えます。既存資産で済ませる設計に倒したので、その判断を含めてまとめておきます。

データソースは既存キャッシュを流用する

横断ダッシュボード(24.5 秒の沈黙を消すために作ったキャッシュ即表示の仕組み)で、各サイトの更新待ちプラグイン情報を localStorage に保存している _updatesDashState という既存のステートがありました。中身はこんな形です。

_updatesDashState = {
  sites: [
    { site_id: "abc...", plugins: [ {...}, {...}, {...} ] },
    { site_id: "def...", plugins: [ ... ] },
  ],
  total_pending_count: 12,
  loadedAt: 1748600000000,
}

サイト ID で照合して plugins.length を取れば、そのサイトの未更新数はそのまま出ます。新規 API も新規キャッシュも要らない。横断ダッシュボードのデータが、サイト一覧バッジのデータでもある、と扱える状況でした。

ステートを増やさないことのメリットは地味に大きくて:

  • メンテナンス実行で _updatesDashState が無効化されると、バッジも自動的に消える(同期コードが要らない)
  • TTL(既存 7 日。後に 30 日への延長 + 部分消去 で改善)も既存設計に乗る
  • 「バッジだけ新鮮で実際の数字は古い」のようなステートの食い違いが原理的に起こらない

新規 API を生やしたい衝動はあったのですが、「既存のステートで答えが出るなら増やさない」を選びました。

バッジ DOM の装着 — ヘルパーに集約

リストビュー / グリッドビュー両方の 🔌 ボタンに同じバッジが要るので、ヘルパーに集約しました。

function _getPendingPluginCountForSite(siteId) {
  // キャッシュから N を取得。未チェックは null(バッジ非表示)
  const entry = _updatesDashState.sites.find(s => s.site_id === siteId);
  return entry ? entry.plugins.length : null;
}

function _attachPendingPluginCountBadge(pluginsBtn, siteId) {
  const count = _getPendingPluginCountForSite(siteId);
  if (count === null || count === 0) return;        // ノイズ削減
  const display = count > 99 ? '99+' : String(count);
  const badge = document.createElement('span');
  badge.className = 'plugin-count-badge';
  badge.textContent = display;
  badge.title = _formatPendingPluginCountTooltip(count);
  pluginsBtn.appendChild(badge);
}

ボタン側には position: relative; を明示的に追加して、絶対配置のバッジが親からはみ出さないようにしておきます(これを忘れるとバッジが画面隅に飛ぶ)。

ノイズを抑えるしきい値

「未更新 0 件のサイト」「未チェックのサイト」にもバッジを出すと、サイト一覧が記号の海になります。実装で入れた割り切りは 2 つです。

  1. 0 件は表示しない — 安心情報よりも危険サインを浮かせる方が一覧の役割に合う。健全なサイトは無印
  2. 未チェックも表示しないnull を返してバッジ非装着。「未更新数を知らない」と「未更新ゼロ」は別の意味なので、UI でも区別する

3 桁以上の数字はバッジ幅を壊すので 99+ 表記で吸収。100+ でなく 99+ にしたのは、横並びの数字バッジで一番幅が安定する書き方だから(GitHub の通知バッジ等と揃えた)。

個別チェックでもバッジが即更新される配線

横断ダッシュボード経由のデータだけだと、「個別にサイトの 🔌 ボタンを開いて中身を確認した時のバッジ反映」が抜けます。要望は「横断 → 個別、どちらでバッジを最新化してもいい」だったので、個別チェック側にもキャッシュ部分更新を入れました。

function _updatePendingPluginCacheForSite(site, plugins) {
  // /api/site_plugins レスポンスから update === 'available' のものだけ抽出
  const pending = plugins.filter(p =>
    p.update === 'available' &&
    p.status !== 'must-use' && p.status !== 'dropin'
  );

  const sites = _updatesDashState.sites;
  const idx = sites.findIndex(s => s.site_id === site._id);

  if (pending.length === 0 && idx >= 0) {
    sites.splice(idx, 1);            // ゼロになったらエントリ自体を削除
  } else if (pending.length > 0) {
    const entry = { site_id: site._id, plugins: pending };
    if (idx >= 0) sites[idx] = entry;
    else sites.push(entry);
  }

  _updatesDashState.total_pending_count =
    sites.reduce((sum, s) => sum + s.plugins.length, 0);
  _saveUpdatesDashStateToLocalStorage();
  filterSites();                      // サイト一覧を即時再描画
}

must-use / dropin を除外しているのは、これらは WordPress の標準的なプラグイン更新フローに乗らない種別だからです(更新ボタンを押しても何も起きない)。ここを混ぜると「バッジは点いてるのに更新できない」事故になります。

filterSites() を最後に呼ぶことで、サイト一覧がその場で再描画されてバッジに新しい値が反映されます。モーダルを閉じる前から数字が変わっているので、「あ、ちゃんと反映されたな」が利用者にも伝わります。

学び — 「既存ステートで答えが出るなら増やすな」

このラウンドから取り出せる原則は次の 3 つでした。

  1. 新しい UI 要素が新しいデータソースを必要とするとは限らない — バッジは「未更新数」を見せるが、その数は既に横断ダッシュボードが持っていた。新規 API・新規キャッシュを生やす前に、既存ステートで答えが出ないか確認する。出るなら増やさない方が、同期コードと食い違いリスクが減る
  2. ノイズ削減はしきい値で表現する — 0 件・未チェックを非表示にすることで、バッジが付いているサイト = 要対応サイトという意味付けが成立する。全件にバッジを出すと記号の海になり、視認性が落ちる
  3. 更新経路は両方から書ける状態にしておく — 横断ダッシュボード経由でも、個別チェック経由でも、同じキャッシュを書き換える。どちらの動線でも同じバッジが正しく更新される状態にすると、「横断は使わない派・個別主義派」のどちらの運用にもフィットする

サイト一覧バッジは見た目こそ小さい機能ですが、裏側で「ステートを増やさない判断」と「ノイズの抑え方」と「両経路からの更新」をきれいに揃えると、追加した瞬間から運用に溶け込むようになります。次の地味改善でも、まず「既存に答えがないか」を疑うのが近道だと思います。