[object Object]

← back to Marketing Command Center

Refine Owned·DW Fleet: surface dormant + repetition health signals + sort

f91859accf3a535e24282a99e47ec8a9778c76ed · 2026-08-25 10:30:13 -0700 · Steve

Turn the owned-account posts view from a flat alpha list into an at-a-glance health
read, operationalizing the two real problems the data showed:
- 💤 dormant flag: 0 posts or none in >14 days (the 6 no-creds accounts, plus any
  that go quiet); the row is tinted so it stands out
- ↻ N× same flag: ≥4 of the last 6 posts share one product title (catches
  @asseeninhotels posting the same SKU 6×, @suedewallpaper 4×)
- summary line: 'X/35 posting · Y dormant · Z repetitive'
- per-account recency ('N posts · Xd ago')
- sort control (Needs attention [default] / Recent activity / Most posts / A→Z),
  localStorage-persisted per the standing sort rule

Verified: summary 29/35 · 6 dormant · 2 repetitive; flags render; attention-sort puts
flagged accounts first; re-sort works; no page errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f91859accf3a535e24282a99e47ec8a9778c76ed
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 10:30:13 2026 -0700

    Refine Owned·DW Fleet: surface dormant + repetition health signals + sort
    
    Turn the owned-account posts view from a flat alpha list into an at-a-glance health
    read, operationalizing the two real problems the data showed:
    - 💤 dormant flag: 0 posts or none in >14 days (the 6 no-creds accounts, plus any
      that go quiet); the row is tinted so it stands out
    - ↻ N× same flag: ≥4 of the last 6 posts share one product title (catches
      @asseeninhotels posting the same SKU 6×, @suedewallpaper 4×)
    - summary line: 'X/35 posting · Y dormant · Z repetitive'
    - per-account recency ('N posts · Xd ago')
    - sort control (Needs attention [default] / Recent activity / Most posts / A→Z),
      localStorage-persisted per the standing sort rule
    
    Verified: summary 29/35 · 6 dormant · 2 repetitive; flags render; attention-sort puts
    flagged accounts first; re-sort works; no page errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/panels/vendors.html | 11 +++++-
 public/panels/vendors.js   | 87 ++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 82 insertions(+), 16 deletions(-)

diff --git a/public/panels/vendors.html b/public/panels/vendors.html
index 0fe80e2..9d9668e 100644
--- a/public/panels/vendors.html
+++ b/public/panels/vendors.html
@@ -9,7 +9,16 @@
 </div>
 <div class="card" id="vend-owned-card">
   <h2>Owned · DW Fleet</h2>
-  <div class="muted" style="margin-bottom:10px">Our own Instagram accounts across the DW fleet. Click any handle to open the account.</div>
+  <div class="row" style="justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px">
+    <div class="muted" id="vend-owned-summary">Our own Instagram accounts across the DW fleet — last 6 posts each.</div>
+    <div style="display:flex;align-items:center;gap:6px"><label class="fld" style="font-size:11px">Sort</label>
+      <select id="vend-owned-sort">
+        <option value="attention">Needs attention</option>
+        <option value="recent">Recent activity</option>
+        <option value="posts">Most posts</option>
+        <option value="alpha">A→Z</option>
+      </select></div>
+  </div>
   <div id="vend-owned-rows"><div class="muted">Loading owned accounts…</div></div>
 </div>
 <div class="card">
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index f641d0f..9b966ea 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -40,8 +40,7 @@ window.MCC_PANELS['vendors'] = {
         for (const k of Object.keys(byHandle)) byHandle[k].sort((a, b) => new Date(b.ts) - new Date(a.ts));
       } catch { /* ledger unreachable — rows render without posts */ }
 
-      const ownedPosts = (handle) => {
-        const list = (byHandle[handle] || []).slice(0, 6);
+      const ownedPosts = (list) => {
         if (!list.length) return '<div class="muted" style="font-size:11px;padding:2px 2px 4px">No posts yet.</div>';
         const items = list.map(p => {
           const cap = esc((p.product_title || '').replace(/\s+/g, ' ').slice(0, 120)) || '(no title)';
@@ -60,19 +59,73 @@ window.MCC_PANELS['vendors'] = {
         return `<div class="ig-list">${items}</div>`;
       };
 
-      const rows = [...owned].sort((a, b) => String(a.name || a.handle).localeCompare(String(b.name || b.handle)));
-      host.innerHTML = rows.map(a => {
-        const h = esc(a.handle);
-        const n = (byHandle[a.handle] || []).length;
-        return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px;background:#faf6ee">
-          <div class="row" style="justify-content:space-between;align-items:center">
-            <div style="min-width:200px"><b>${esc(a.name || a.handle)}</b> <span class="pill">OURS</span></div>
-            <div style="flex:1"><a class="lnk" href="https://www.instagram.com/${h}/" target="_blank" rel="noopener noreferrer">@${h} ↗</a></div>
-            <div style="min-width:70px;text-align:right;font-variant-numeric:tabular-nums" title="posts in the fleet ledger">${n ? n + ' posts' : ''}</div>
-          </div>
-          ${ownedPosts(a.handle)}
-        </div>`;
-      }).join('');
+      // Per-account health signals computed from the ledger — surfaced so the two
+      // real problems in the data (dormant accounts + one-product repetition) are
+      // visible at a glance instead of hidden in a flat alpha list.
+      const DORMANT_DAYS = 14, REP_MIN = 4;   // repetitive = ≥4 of the last 6 posts share one title
+      const meta = owned.map(a => {
+        const posts = (byHandle[a.handle] || []);
+        const last6 = posts.slice(0, 6);
+        const newestTs = posts.length ? posts[0].ts : null;
+        const daysOld = newestTs ? (Date.now() - new Date(newestTs)) / 864e5 : Infinity;
+        const freq = {};
+        let repCount = 0;
+        for (const p of last6) { const t = (p.product_title || '').trim().toLowerCase(); if (!t) continue; freq[t] = (freq[t] || 0) + 1; if (freq[t] > repCount) repCount = freq[t]; }
+        const dormant = posts.length === 0 || daysOld > DORMANT_DAYS;
+        const repetitive = last6.length >= REP_MIN && repCount >= REP_MIN;
+        return { a, posts, last6, count: posts.length, newestTs, daysOld, dormant, repetitive, repCount };
+      });
+
+      const SORT_KEY = 'mcc_vendors_owned_sort';
+      const sortSel = root.querySelector('#vend-owned-sort');
+      if (sortSel) sortSel.value = localStorage.getItem(SORT_KEY) || 'attention';
+      const sortMeta = (mode) => {
+        const alpha = (x, y) => String(x.a.name || x.a.handle).localeCompare(String(y.a.name || y.a.handle));
+        const arr = meta.slice();
+        if (mode === 'alpha') return arr.sort(alpha);
+        if (mode === 'posts') return arr.sort((x, y) => y.count - x.count || alpha(x, y));
+        if (mode === 'recent') return arr.sort((x, y) => (y.newestTs ? new Date(y.newestTs) : 0) - (x.newestTs ? new Date(x.newestTs) : 0) || alpha(x, y));
+        // 'attention' (default): worst first — dormant + repetitive on top, then stalest.
+        const score = m => (m.dormant ? 2 : 0) + (m.repetitive ? 1 : 0);
+        return arr.sort((x, y) => score(y) - score(x) || (y.daysOld - x.daysOld) || alpha(x, y));
+      };
+
+      const flag = (bg, fg, txt, title) => `<span class="pill" style="background:${bg};color:${fg}" title="${esc(title)}">${txt}</span>`;
+      const renderOwned = (mode) => {
+        host.innerHTML = sortMeta(mode).map(m => {
+          const a = m.a, h = esc(a.handle);
+          const recency = m.count ? ago(m.newestTs) : '—';
+          const flags = [
+            m.dormant ? flag('#f7e2e2', '#a53a3a', '💤 dormant', m.count ? `No posts in ${Math.round(m.daysOld)} days` : 'No posts yet (account has no creds / hasn’t published)') : '',
+            m.repetitive ? flag('#fdf3dc', '#8a6a1e', `↻ ${m.repCount}× same`, `${m.repCount} of the last ${m.last6.length} posts are the same product — vary the content`) : '',
+          ].filter(Boolean).join(' ');
+          return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px;background:${m.dormant ? '#fbf3f2' : '#faf6ee'}">
+            <div class="row" style="justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
+              <div style="min-width:200px"><b>${esc(a.name || a.handle)}</b> <span class="pill">OURS</span> ${flags}</div>
+              <div style="flex:1"><a class="lnk" href="https://www.instagram.com/${h}/" target="_blank" rel="noopener noreferrer">@${h} ↗</a></div>
+              <div style="min-width:120px;text-align:right;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--muted,#8a8372)" title="posts in the fleet ledger">${m.count ? `${m.count} posts · ${recency}` : ''}</div>
+            </div>
+            ${ownedPosts(m.last6)}
+          </div>`;
+        }).join('');
+      };
+
+      // Summary line — the at-a-glance fleet health read.
+      const summaryEl = root.querySelector('#vend-owned-summary');
+      if (summaryEl) {
+        const posting = meta.filter(m => m.count).length;
+        const dormant = meta.filter(m => m.dormant).length;
+        const repetitive = meta.filter(m => m.repetitive).length;
+        summaryEl.innerHTML = `<b>${posting}</b>/${meta.length} posting · ` +
+          `<b style="color:${dormant ? '#a53a3a' : 'inherit'}">${dormant}</b> dormant · ` +
+          `<b style="color:${repetitive ? '#8a6a1e' : 'inherit'}">${repetitive}</b> repetitive — last 6 posts each.`;
+      }
+
+      renderOwned(sortSel ? sortSel.value : 'attention');
+      if (sortSel && !sortSel.dataset.wired) {
+        sortSel.dataset.wired = '1';
+        sortSel.onchange = () => { localStorage.setItem(SORT_KEY, sortSel.value); renderOwned(sortSel.value); };
+      }
     })();
 
     // Amplify kit registry — raw (UNESCAPED) clipboard text + X-composer URL per
@@ -197,6 +250,10 @@ window.MCC_PANELS['vendors'] = {
         image: p.image || '',
         permalink,
         srcCaption: fullCap,
+        // Carried for refresh-on-click matching (TK-10843): the IG media id is the
+        // strongest key, then permalink, then timestamp+caption.
+        mediaId: p.id || '',
+        timestamp: p.timestamp || '',
       };
       return { id, xUrl };
     };

← 6b6da55 Add "Make it ours" DW-original creative system to #vendors a  ·  back to Marketing Command Center  ·  Owned·DW Fleet: add 7-day cadence + 'slowing' signal 9690867 →