[object Object]

← back to Analytics Chips

add categorized fleet view at /categories.html with sort + density slider

2ae67533306d350770108ab9a85a5e3d83211669 · 2026-05-07 14:09:37 -0700 · SteveStudio2

Files touched

Diff

commit 2ae67533306d350770108ab9a85a5e3d83211669
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 14:09:37 2026 -0700

    add categorized fleet view at /categories.html with sort + density slider
---
 public/categories.html | 234 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 234 insertions(+)

diff --git a/public/categories.html b/public/categories.html
new file mode 100644
index 0000000..4d156c1
--- /dev/null
+++ b/public/categories.html
@@ -0,0 +1,234 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Fleet · Categories</title>
+<style>
+  :root {
+    --bg: #0d0d0e; --bg-2: #161618; --bg-3: #1f1f22;
+    --ink: #e9e9eb; --ink-mute: #8a8a92; --ink-faint: #5a5a62;
+    --rule: #2a2a2e; --gold: #d4a04a; --good: #8fb89a; --warn: #d8b04a; --bad: #c87a6e;
+    --serif: ui-serif, Georgia, "Times New Roman", serif;
+    --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+    --card-min: 240px;
+  }
+  * { box-sizing: border-box; }
+  html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); font: 400 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; min-height: 100vh; }
+  a { color: var(--gold); text-decoration: none; }
+  a:hover { text-decoration: underline; }
+
+  header {
+    display: flex; align-items: baseline; gap: 14px; padding: 18px 26px;
+    border-bottom: 1px solid var(--rule); background: var(--bg-2); position: sticky; top: 0; z-index: 5; flex-wrap: wrap;
+  }
+  header h1 { margin: 0; font-family: var(--serif); font-size: 22px; font-weight: 500; }
+  header h1 em { font-style: italic; color: var(--gold); }
+  header nav { display: flex; gap: 14px; margin-left: 16px; }
+  header nav a { font-family: var(--mono); font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase; color: var(--ink-mute); }
+  header nav a.active { color: var(--gold); }
+  header .controls { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-wrap: wrap; }
+  header .controls input { background: var(--bg-3); color: var(--ink); border: 1px solid var(--rule); padding: 7px 12px; border-radius: 2px; font-family: var(--mono); font-size: 12px; width: 220px; outline: none; }
+  header .controls input:focus { border-color: var(--gold); }
+  header .controls select { background: var(--bg-3); color: var(--ink); border: 1px solid var(--rule); padding: 7px 12px; border-radius: 2px; font-family: var(--mono); font-size: 11px; cursor: pointer; }
+  header .controls .density { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 10px; color: var(--ink-mute); letter-spacing: 0.10em; text-transform: uppercase; }
+  header .controls input[type=range] { width: 110px; cursor: pointer; }
+  header .meta { font-family: var(--mono); font-size: 11px; color: var(--ink-mute); letter-spacing: 0.10em; text-transform: uppercase; width: 100%; padding-top: 6px; }
+  header .meta strong { color: var(--gold); font-weight: 600; }
+
+  main { padding: 22px 26px 60px; max-width: 1600px; margin: 0 auto; }
+
+  .cat { margin-bottom: 38px; }
+  .cat h2 {
+    font-family: var(--serif); font-size: 18px; font-weight: 500; margin: 0 0 6px;
+    display: flex; align-items: baseline; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--rule);
+  }
+  .cat h2 em { color: var(--gold); font-style: italic; }
+  .cat h2 .ct { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; color: var(--ink-faint); text-transform: uppercase; margin-left: auto; }
+  .cat .blurb {
+    font-family: var(--serif); font-size: 13px; color: var(--ink-mute); line-height: 1.55;
+    margin: 8px 0 14px; padding-left: 12px; border-left: 2px solid var(--gold); max-width: 920px;
+  }
+  .cat .blurb b { color: var(--ink); font-weight: 500; }
+
+  .chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 10px; }
+  .chip {
+    background: var(--bg-2); border: 1px solid var(--rule); border-radius: 4px;
+    padding: 12px 14px 14px; transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
+    position: relative; overflow: hidden; display: flex; flex-direction: column; gap: 6px; min-height: 100px;
+  }
+  .chip:hover { transform: translateY(-1px); border-color: var(--gold); background: var(--bg-3); }
+  .chip .accent { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--gold); }
+  .chip .row1 { display: flex; align-items: baseline; gap: 8px; }
+  .chip .name { font-family: var(--serif); font-size: 14px; font-weight: 500; flex: 1; }
+  .chip .gid { font-family: var(--mono); font-size: 9px; color: var(--ink-faint); letter-spacing: 0.06em; padding: 2px 5px; background: var(--bg-3); border-radius: 2px; }
+  .chip .domain { font-family: var(--mono); font-size: 10px; color: var(--ink-mute); letter-spacing: 0.04em; }
+  .chip .metrics { display: flex; gap: 10px; margin-top: auto; padding-top: 8px; border-top: 1px dashed var(--rule); }
+  .chip .metric { display: flex; flex-direction: column; }
+  .chip .metric .num { font-family: var(--mono); font-size: 13px; color: var(--ink); font-weight: 600; line-height: 1; }
+  .chip .metric .lbl { font-family: var(--mono); font-size: 8px; letter-spacing: 0.10em; text-transform: uppercase; color: var(--ink-faint); margin-top: 3px; }
+  .chip .empty-msg { font-family: var(--mono); font-size: 10px; color: var(--ink-faint); font-style: italic; margin-top: auto; padding-top: 8px; border-top: 1px dashed var(--rule); }
+  .footer { text-align: center; padding: 24px 0; color: var(--ink-faint); font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; }
+</style>
+</head><body>
+
+<header>
+  <h1>Fleet <em>Categories</em></h1>
+  <nav>
+    <a href="/">all chips</a>
+    <a href="/categories.html" class="active">categories</a>
+  </nav>
+  <div class="controls">
+    <input id="filter" placeholder="filter by domain…" autocomplete="off">
+    <select id="sort">
+      <option value="alpha">A→Z</option>
+      <option value="users">Users 7d ↓</option>
+      <option value="sessions">Sessions 7d ↓</option>
+    </select>
+    <span class="density">density <input id="density" type="range" min="160" max="380" step="20"></span>
+  </div>
+  <div class="meta"><strong id="ct">…</strong> properties · <strong id="users-total">…</strong> users · <strong id="sessions-total">…</strong> sessions · <span id="status">loading</span></div>
+</header>
+
+<main id="cats"><div class="empty" style="text-align:center;padding:80px 0;color:var(--ink-faint);font-style:italic">Loading fleet…</div></main>
+
+<div class="footer">analytics-chips · :9786 · /categories</div>
+
+<script>
+const $ = (s) => document.querySelector(s);
+const esc = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+const fmtNum = (n) => n==null?'—' : n>=1000 ? (n/1000).toFixed(1)+'k' : String(n);
+
+const CATEGORIES = [
+  {
+    id: 'material',
+    title: 'Material-first wallcovering niches',
+    blurb: 'SEO-pure intent. Someone searching "silk wallpaper" has self-selected to the top of the trade funnel — they want the material, not a brand. Each domain rank-targets its term against a dw_unified subset filtered by niche keyword. <b>Cheap to operate, zero brand-building cost</b>, and every site narrows the funnel to memo-sample requests. Risk: doorway-page penalty if templates look identical — that\'s why per-site visual differentiation matters.',
+    matches: ['silkwallpaper','silkwallcoverings','linenwallpaper','jutewallpaper','raffiawallcoverings','raffiawalls','corkwallcovering','fabricwallpaper','vinylwallpaper','suedewallpaper','micawallpaper','glassbeadedwallpaper','embroideredwallpaper','goldleafwallpaper','silverleafwallpaper','metallicwallpaper','mylarwallpaper','glitterwalls','grassclothwallpaper','grassclothwallcoverings','recycledwallpaper','flockedwallpaper','blockprintedwallpaper','screenprintedwallpaper','handcraftedwallpaper','pastelwallpaper','naturalwallcoverings','greenwallcoverings','stringwallpaper','madagascarwallpaper'],
+  },
+  {
+    id: 'decade',
+    title: 'Decade niches',
+    blurb: 'Captures both restoration buyers (period-correct) and aesthetic shoppers (cottagecore, midcentury, deco). Shopify products tag once, surface on the matching decade site. Monetizable on long-tail "<b>1920s art deco wallpaper</b>" type queries that big-box retailers don\'t optimize for.',
+    matches: ['1800swallpaper','1890swallpaper','1900swallpaper','1920swallpaper','1930swallpaper','1940swallpaper','1950swallpaper','1960swallpaper','1970swallpaper','1980swallpaper','retrowalls','restorationwallpaper','agedwallpaper','saloonwallpaper','wallpapersback','museumwallpaper'],
+  },
+  {
+    id: 'usecase',
+    title: 'Use-case / vertical niches',
+    blurb: 'B2B specifier traffic. A hotel architect doesn\'t care about your brand — they care that the line is rated <b>Type II vinyl, fire-code compliant, in-stock</b>. Each site filters dw_unified to the contract-grade subset. Higher AOV than residential, longer sales cycles, but the PO is six-figure.',
+    matches: ['hospitalitywallpaper','hospitalitywallcoverings','healthcarewallpaper','hotelwallcoverings','restaurantwallpaper','contractwallpaper','apartmentwallpaper','architecturalwallcoverings','selfadhesivewallpaper','textilewallpaper'],
+  },
+  {
+    id: 'brand',
+    title: 'Brand storefronts',
+    blurb: 'Full-brand experiences, not SEO funnels. dw-site-build playbook with editorial copy, room settings, lookbooks. Conversion comes from <b>designer aesthetic, not search intent</b>. Different KPI: time-on-site + memo-sample rate, not bounce rate.',
+    matches: ['philipperomano','novasuede','hollywoodwallcoverings','hollywood-wallcoverings','wallpaperondemand','wallsondemand','muralsondemand','wallpapercanada','fabricsondemand'],
+  },
+  {
+    id: 'directory',
+    title: 'Directories / marketplaces',
+    blurb: 'Network-effect plays. Each row is a record someone wants to claim or contact. Revenue model is <b>claim-fee + lead-routing</b>, not transactional. Standalone PG schemas, own auth/admin layer — different project style from the wallpaper microsites.',
+    matches: ['nationalpaperhangers','wholivedthere','claimmyaddress','bubbesblock','losgardeners','ffepurchasing','studentdebtlawyers','lawyers'],
+  },
+  {
+    id: 'tool',
+    title: 'Tools / utilities',
+    blurb: 'Either lead-capture tools that funnel into the directories, or experimental brand bets. Mostly client-side widgets, cheap to maintain. Either they hit, or they don\'t — <b>low-cost optionality</b>.',
+    matches: ['boomercalc','67calc','goodquestion','bubbe','deadradio','iwascute','i-was-cute'],
+  },
+  {
+    id: 'platform',
+    title: 'Domain assets / platform',
+    blurb: '<b>Steve-as-platform.</b> agentabrams.com is the umbrella domain that hosts subdomain spinoffs (forza, lawyers, deadradio). Lets you ship a new brand in 20 minutes without buying a new domain. greendomainbrokers is the meta-business if any of these get acquisition interest.',
+    matches: ['agentabrams','greendomainbrokers','forza','nosarabeachfrontrentals'],
+  },
+];
+
+let RAW = [];
+
+function bareName(domain) {
+  return String(domain || '').replace(/^www\./,'').replace(/\.[a-z]{2,}$/,'').toLowerCase();
+}
+
+function categorize(domain) {
+  const stem = bareName(domain);
+  for (const cat of CATEGORIES) {
+    if (cat.matches.includes(stem)) return cat.id;
+  }
+  return 'uncat';
+}
+
+function applyDensity(px) {
+  document.documentElement.style.setProperty('--card-min', px + 'px');
+  $('#density').value = px;
+  localStorage.setItem('cat.density', px);
+}
+
+function render() {
+  const filter = $('#filter').value.trim().toLowerCase();
+  const sort = $('#sort').value;
+  const filtered = RAW.filter(p => !filter || p.domain.toLowerCase().includes(filter));
+  filtered.sort((a,b) => sort==='users' ? (b.users||0)-(a.users||0)
+                       : sort==='sessions' ? (b.sessions||0)-(a.sessions||0)
+                       : a.domain.localeCompare(b.domain));
+
+  const buckets = {};
+  for (const p of filtered) {
+    const c = categorize(p.domain);
+    (buckets[c] = buckets[c] || []).push(p);
+  }
+
+  const groups = CATEGORIES.concat([{id:'uncat',title:'Uncategorized',blurb:'Properties not yet mapped — add their stem to the matching category in <code>categories.html</code> CATEGORIES.matches.',matches:[]}]);
+
+  const html = groups.map(cat => {
+    const items = buckets[cat.id] || [];
+    if (!items.length && cat.id !== 'uncat') return '';
+    if (!items.length) return '';
+    return `<section class="cat" id="cat-${cat.id}">
+      <h2>${esc(cat.title)} <em>·</em> ${items.length} <span class="ct">${items.length} of fleet</span></h2>
+      <div class="blurb">${cat.blurb}</div>
+      <div class="chips">${items.map(p => `
+        <a class="chip" href="https://${esc(p.domain)}" target="_blank" rel="noopener">
+          <span class="accent"></span>
+          <div class="row1"><div class="name">${esc(bareName(p.domain))}</div><div class="gid">${esc(p.gid||'')}</div></div>
+          <div class="domain">${esc(p.domain)}</div>
+          ${p.users || p.sessions ? `<div class="metrics">
+            <div class="metric"><div class="num">${fmtNum(p.users)}</div><div class="lbl">users</div></div>
+            <div class="metric"><div class="num">${fmtNum(p.sessions)}</div><div class="lbl">sessions</div></div>
+            <div class="metric"><div class="num">${fmtNum(p.pageviews)}</div><div class="lbl">views</div></div>
+          </div>` : `<div class="empty-msg">no traffic last 7d</div>`}
+        </a>`).join('')}</div>
+    </section>`;
+  }).join('');
+
+  $('#cats').innerHTML = html || '<div class="empty" style="text-align:center;padding:80px 0;color:var(--ink-faint);font-style:italic">No properties match filter.</div>';
+  $('#ct').textContent = filtered.length;
+  $('#users-total').textContent = fmtNum(filtered.reduce((s,p)=>s+(p.users||0),0));
+  $('#sessions-total').textContent = fmtNum(filtered.reduce((s,p)=>s+(p.sessions||0),0));
+}
+
+async function load() {
+  $('#status').textContent = 'fetching';
+  try {
+    const r = await fetch('/api/metrics');
+    const j = await r.json();
+    RAW = j.metrics || [];
+    $('#status').textContent = j.cached ? 'cached' : 'fresh';
+  } catch (e) {
+    $('#status').textContent = 'error';
+    RAW = [];
+  }
+  render();
+}
+
+$('#filter').addEventListener('input', () => { render(); localStorage.setItem('cat.filter', $('#filter').value); });
+$('#sort').addEventListener('change', () => { render(); localStorage.setItem('cat.sort', $('#sort').value); });
+$('#density').addEventListener('input', () => applyDensity(parseInt($('#density').value,10)));
+
+// Restore persisted controls
+$('#filter').value = localStorage.getItem('cat.filter') || '';
+$('#sort').value = localStorage.getItem('cat.sort') || 'alpha';
+applyDensity(parseInt(localStorage.getItem('cat.density') || '240', 10));
+
+load();
+</script>
+</body></html>

← 1f93870 untrack node_modules per standing rule (was tracking 2795 fi  ·  back to Analytics Chips  ·  gitignore: protect .env* from accidental commits per MEMORY be62a0e →