[object Object]

← back to Prestige Car Wash

Analytics-lite: first-party, cookie-free conversion tracking + admin card

e694e62700a62afa6d0504c6ea8d29d45687d352 · 2026-07-27 07:27:46 -0700 · Steve Abrams

Self-hosted aggregate counters (pageview / form_start / form_submit) -> honest view→book
conversion, surfaced on the admin Overview. No cookies, no IP, no PII, no consent banner, no
GA. sendBeacon pageview (all customer pages via decorate), funnel events on the contact form.
Contrarian FIX-FIRST: added rateLimit(60/min) to the public /api/track (was an unauthenticated
unrated disk-write), atomic temp+rename write (no corruption), and the conversion shows as
~X% (est.) so its approximation is visible, not buried. Bounded to last 120 day-buckets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e694e62700a62afa6d0504c6ea8d29d45687d352
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 07:27:46 2026 -0700

    Analytics-lite: first-party, cookie-free conversion tracking + admin card
    
    Self-hosted aggregate counters (pageview / form_start / form_submit) -> honest view→book
    conversion, surfaced on the admin Overview. No cookies, no IP, no PII, no consent banner, no
    GA. sendBeacon pageview (all customer pages via decorate), funnel events on the contact form.
    Contrarian FIX-FIRST: added rateLimit(60/min) to the public /api/track (was an unauthenticated
    unrated disk-write), atomic temp+rename write (no corruption), and the conversion shows as
    ~X% (est.) so its approximation is visible, not buried. Bounded to last 120 day-buckets.
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/admin/index.html | 22 ++++++++++++++++++++--
 public/contact.html     |  5 ++++-
 server.js               | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 72 insertions(+), 3 deletions(-)

diff --git a/public/admin/index.html b/public/admin/index.html
index 38fc33b..de0dd12 100644
--- a/public/admin/index.html
+++ b/public/admin/index.html
@@ -402,12 +402,29 @@ function funnelCard(f){
     ${f.st.new>0?`<div class="blurb" style="color:var(--warn)">⚠️ ${f.st.new} new lead${f.st.new!==1?'s':''} waiting${f.cold>0?` — <b style="color:var(--bad)">${f.cold} going cold (&gt;48h)</b>`:''} → <a href="#" onclick="go('leads');return false" style="color:var(--brand)">open Leads</a></div>`:'<div class="blurb" style="color:var(--mut)">No unworked leads — all caught up.</div>'}
   </div></div>`;
 }
+// Self-hosted analytics-lite (2026-07-27): first-party aggregate counts → honest conversion.
+function analyticsCard(a){
+  a=a||{}; const t7=a.last7||{}, tot=a.total||{}, td=a.today||{}; const n=v=>v||0;
+  return `<div class="card"><div class="body">
+    <div class="ttl">📊 Site analytics <span class="badge b-mut" style="font-weight:600">first-party · no cookies</span></div>
+    <div class="stat">
+      <div class="s"><b>${n(td.pageview)}</b><span>views today</span></div>
+      <div class="s"><b>${n(t7.pageview)}</b><span>views · 7d</span></div>
+      <div class="s"><b>${n(t7.form_start)}</b><span>form starts · 7d</span></div>
+      <div class="s"><b>${n(t7.form_submit)}</b><span>bookings · 7d</span></div>
+      <div class="s"><b>~${a.conversion7!=null?a.conversion7:0}%</b><span>view→book · 7d (est.)</span></div>
+    </div>
+    <div class="kv"><span>All-time views / bookings</span><b>${n(tot.pageview)} / ${n(tot.form_submit)}</b></div>
+    <div class="blurb" style="color:var(--mut)">Aggregate first-party counts — no cookies, no personal data. Visitors who block JS or bots that don't run it aren't counted, so treat these as directional.</div>
+  </div></div>`;
+}
 function renderOverview(){
   Promise.all([
     fetch('/api/health').then(r=>r.json()),
     fetch('/api/admin/credentials').then(r=>r.json()).catch(()=>[]),
-    fetch('/api/admin/leads').then(r=>r.json()).catch(()=>[])
-  ]).then(([h,creds,leads])=>{
+    fetch('/api/admin/leads').then(r=>r.json()).catch(()=>[]),
+    fetch('/api/admin/analytics').then(r=>r.json()).catch(()=>({}))
+  ]).then(([h,creds,leads,an])=>{
     const c=h.counts;
     const places=(creds||[]).find(k=>k.key==='GOOGLE_PLACES_API_KEY');
     const placesTag=places&&places.present
@@ -418,6 +435,7 @@ function renderOverview(){
         ${Object.entries(c).map(([k,v])=>`<div class="s"><b>${v}</b><span>${k}</span></div>`).join('')}
       </div>
       ${funnelCard(leadFunnel(leads))}
+      ${analyticsCard(an)}
       <div class="card"><div class="body">
         <div class="ttl">Growth command center</div>
         <div class="blurb">Every bucket below is seeded from live SFV market research. Buckets with a search box are data-driven; the rest (best-times, Google, credentials) are tools. Start in <b>✨ Compose & Post</b> to build a social post from your real photos/videos and open each network's composer in one click. Cards show their created date + time. All write/spend/post actions are gated.</div>
diff --git a/public/contact.html b/public/contact.html
index 53e3492..ef5d0c4 100644
--- a/public/contact.html
+++ b/public/contact.html
@@ -91,6 +91,9 @@ fetch('/api/services?sort=featured').then(r=>r.json()).then(list=>{
     }
   }
 });
+// analytics-lite beacon (aggregate only, no PII) — booking-funnel start/submit events.
+function track(ev){try{var b=JSON.stringify({event:ev});if(navigator.sendBeacon){navigator.sendBeacon('/api/track',new Blob([b],{type:'application/json'}));}else{fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true});}}catch(e){}}
+(function(){var f=document.getElementById('f'),fired=false;if(f)f.addEventListener('input',function(){if(!fired){fired=true;track('form_start');}});})();
 let _submitting=false;
 document.getElementById('f').addEventListener('submit',async e=>{
   e.preventDefault();
@@ -108,7 +111,7 @@ document.getElementById('f').addEventListener('submit',async e=>{
   try{
     const r=await fetch('/api/contact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
     const j=await r.json();
-    if(j.ok){ok.textContent=j.message;ok.style.borderColor='var(--good)';ok.style.color='var(--good)';ok.style.background='rgba(58,210,159,.12)';ok.style.display='block';form.reset();window.scrollTo({top:0,behavior:window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches?'auto':'smooth'});}
+    if(j.ok){track('form_submit');ok.textContent=j.message;ok.style.borderColor='var(--good)';ok.style.color='var(--good)';ok.style.background='rgba(58,210,159,.12)';ok.style.display='block';form.reset();window.scrollTo({top:0,behavior:window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches?'auto':'smooth'});}
     else{showErr(j.error||'Something went wrong — please try again.');}
   }catch{ showErr('Could not reach the server — please check your connection and try again.'); }
   finally{ _submitting=false; if(btn){btn.disabled=false;btn.textContent=btn.dataset.t||'Request booking';} }
diff --git a/server.js b/server.js
index 9e87a7d..f15fc33 100644
--- a/server.js
+++ b/server.js
@@ -383,6 +383,50 @@ app.post('/api/admin/lead-status', (req, res) => {
   } catch { res.status(500).json({ ok: false, error: 'could not save status' }); }
 });
 
+// ── Self-hosted, privacy-friendly analytics-lite (DTD 2026-07-27) ──────────────────────
+// First-party AGGREGATE counts ONLY — no cookies, no IP, no per-user data — so it needs no
+// consent banner and stores nothing we can't stand behind. Gives the owner the denominator
+// (views) behind their lead counts => a real, honestly-measured conversion rate. NOT GA.
+const ANALYTICS_EVENTS = ['pageview', 'form_start', 'form_submit'];
+const analyticsPath = () => path.join(__dirname, 'reports', 'analytics.json');
+function readAnalytics() { try { return JSON.parse(fs.readFileSync(analyticsPath(), 'utf8')); } catch { return {}; } }
+const dayKeyLA = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' }); // YYYY-MM-DD, shop tz
+
+// Public beacon. Whitelisted events only; unknown events are silently ignored (204, no error
+// surface). Aggregate increment only — never stores anything about who the visitor is.
+app.post('/api/track', rateLimit(60, 60 * 1000), (req, res) => {
+  const ev = String((req.body || {}).event || '');
+  if (!ANALYTICS_EVENTS.includes(ev)) return res.status(204).end();
+  try {
+    const a = readAnalytics(); const day = dayKeyLA();
+    a[day] = a[day] || {}; a[day][ev] = (a[day][ev] || 0) + 1;
+    const days = Object.keys(a).sort();                       // keep the file bounded (last 120 days)
+    if (days.length > 120) for (const d of days.slice(0, days.length - 120)) delete a[d];
+    fs.mkdirSync(path.join(__dirname, 'reports'), { recursive: true });
+    // Atomic write (temp + rename) so a partial/concurrent write can never leave a corrupt
+    // analytics.json that a reader would choke on.
+    const tmp = analyticsPath() + '.tmp';
+    fs.writeFileSync(tmp, JSON.stringify(a));
+    fs.renameSync(tmp, analyticsPath());
+  } catch { /* analytics must NEVER break a page — swallow */ }
+  res.status(204).end();
+});
+
+// Admin: aggregate analytics (today / last 7 days / all-time + honest conversion rate).
+app.get('/api/admin/analytics', (req, res) => {
+  const a = readAnalytics();
+  const sum = keys => keys.reduce((o, d) => { const b = a[d] || {}; ANALYTICS_EVENTS.forEach(e => o[e] = (o[e] || 0) + (b[e] || 0)); return o; }, {});
+  const allDays = Object.keys(a).sort();
+  const last7 = []; for (let i = 0; i < 7; i++) { const d = new Date(); d.setDate(d.getDate() - i); last7.push(d.toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' })); }
+  const conv = t => t.pageview ? Math.round((t.form_submit || 0) / t.pageview * 1000) / 10 : 0; // submits per 100 views
+  const t7 = sum(last7), tot = sum(allDays);
+  res.json({
+    today: sum([dayKeyLA()]), last7: t7, total: tot,
+    conversion7: conv(t7), conversionTotal: conv(tot),
+    series: allDays.slice(-14).map(d => ({ date: d, ...ANALYTICS_EVENTS.reduce((o, e) => (o[e] = (a[d] || {})[e] || 0, o), {}) }))
+  });
+});
+
 // Public: HONEST "typical wait" estimate derived from the demand model.
 // It is NOT a live queue count — it's a transparent function of best-times.json
 // demand for the current weekday, always labeled as an estimate, so we never
@@ -792,6 +836,10 @@ function decorate(html, pagePath, ld) {
     inject += `\n<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
   }
   inject += gaSnippet();
+  // First-party pageview beacon (analytics-lite) — sendBeacon so it never blocks paint; the
+  // /api/track endpoint only increments an aggregate counter (no cookie/IP/PII). Customer
+  // pages only (admin isn't served through decorate, so the owner's own visits aren't counted).
+  inject += `\n<script>(function(){try{var b=JSON.stringify({event:'pageview'});if(navigator.sendBeacon){navigator.sendBeacon('/api/track',new Blob([b],{type:'application/json'}))}else{fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true})}}catch(e){}})();</script>`;
   return html.replace('</head>', inject + '\n</head>');
 }
 function pageHtml(file, pagePath, ld) {

← acd004a A11y + perf pass: focus-visible, reduced-motion, aria labels  ·  back to Prestige Car Wash  ·  Drop non-existent apex: prestige.agentabrams.com is the perm 4937b3b →