Skip to content

A pending-plugin-count badge on the 🔌 button — reusing the dashboard cache instead of doubling state

A client asked: “After I run a cross-site update check, can each site show — right in the site list — how many plugin updates are still pending?” Visually the answer was obvious: a small red badge on the top-right of the 🔌 plugins button, like an unread-notification count. Easy to specify.

The harder question was where the data comes from. We could have added a fresh API endpoint and a new cache to hold “pending count per site.” But doing that would have doubled state management, and we already had a cache that knew this. We routed through the existing one. Here’s the reasoning behind that decision.

Reuse the dashboard cache as the data source

The cross-site updates dashboard (the one we wrote about in killing the 24.5-second silence with a cache-first design) already kept each site’s pending plugins in a localStorage-backed state called _updatesDashState. Its shape:

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

Look up by site_id, take plugins.length, and you have the badge’s number. No new API, no new cache. The data that powers the cross-site dashboard is also the data that powers the site-list badge.

The win of not adding state is quiet but real:

  • When a maintenance run invalidates _updatesDashState, the badge disappears automatically (no sync code to write)
  • The TTL (originally 7 days; later extended to 30 days with partial invalidation) inherits from the existing design
  • The badge and the underlying count can’t drift — there’s no second copy to fall out of step

There’s always a temptation to spin up a new endpoint for a new UI element. The rule we settled on: if the existing state answers it, don’t add more.

Attaching the badge — consolidate into helpers

Both the list view and grid view need the same badge on the 🔌 button, so the logic lives in helpers.

function _getPendingPluginCountForSite(siteId) {
  // null = unchecked (badge not shown); a number = the actual pending count
  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;        // reduce noise
  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);
}

A small but easy-to-miss detail: the button itself needs position: relative; so the absolutely-positioned badge doesn’t fly off the parent. Without it, the badge ends up in a corner of the screen.

Thresholds that cut noise

If we showed a badge on every site — including “zero pending” and “never checked” — the site list would turn into a sea of icons. The two cuts we made:

  1. Don’t show 0 — a healthy site has no badge. Surfacing danger signals beats surfacing reassurance for an inventory view
  2. Don’t show “unchecked” — return null, no badge attached. “I don’t know the count” and “the count is zero” mean different things, and the UI should preserve that distinction

For three-digit counts, the layout breaks unless you cap. We use 99+, not 100+ — it keeps the badge width consistent across rows, and it’s the convention readers already know from GitHub-style notification counters.

Per-site checks update the badge too

Pulling data only from the cross-site dashboard misses one path: “I clicked the 🔌 on a single site, looked inside, and now I want the badge updated.” The request was “either path should refresh the badge,” so the per-site check writes back to the same cache.

function _updatePendingPluginCacheForSite(site, plugins) {
  // From /api/site_plugins, keep only "update available" — exclude must-use / dropin
  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);            // drop the entry entirely when count hits zero
  } 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();                      // immediately re-render the site list
}

The must-use and dropin exclusions matter — those plugins don’t go through the standard WordPress update flow, and counting them would create a “badge says update available, but the update button does nothing” bug.

The trailing filterSites() re-renders the site list right then, so the new count is visible before the user even closes the modal. That “yep, it took” feedback is what makes the path feel solid.

Lessons — “if existing state answers it, don’t add more”

Three principles to take away:

  1. A new UI element doesn’t necessarily need a new data source. The badge displayed “pending count,” but that count was already known to the cross-site dashboard. Before adding a new API or cache, check whether existing state can answer the question. If it can, don’t add more — you’ll save yourself sync code and drift risk
  2. Express noise reduction as thresholds. Hiding the zero and unchecked states means “badge present = needs attention.” Showing the badge on every site reduces it to background noise
  3. Make both write paths converge on the same cache. Whether the user updates via the cross-site dashboard or via a per-site check, the same cache is written. That way “I prefer the dashboard” and “I prefer per-site” workflows both end up with correct badges

A site-list badge looks like a small feature, but underneath, the lessons that pay off are about not adding state when you don’t have to, picking thresholds that mean something, and converging your update paths. Next time something small needs adding, the cheapest move is to ask: is the answer already in something we have?