Files
quikcue-site/html/live/index.html
T

335 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QuikCue — Live Build Log</title>
<meta name="description" content="100 systems shipped. Every deployment, every fix — logged live from production.">
<meta property="og:title" content="QuikCue — Live Build Log">
<meta property="og:description" content="100 systems shipped. Real-time production log. Full transparency.">
<link rel="canonical" href="https://quikcue.com/live">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/live.css">
</head>
<body>
<nav>
<div class="nav-inner">
<a href="/" class="logo">QuikCue</a>
<div class="nav-links">
<a href="/#work">Work</a>
<a href="/live" class="active">Live Feed</a>
<a href="/#contact" class="nav-cta">Talk to us</a>
</div>
</div>
</nav>
<!-- ═══ HERO ═══ -->
<section class="lv-hero">
<div class="lv-hero-glow"></div>
<div class="lv-hero-glow2"></div>
<div class="container">
<div class="lv-hero-badge">
<span class="lv-pulse"></span>
Live from production
</div>
<h1 class="lv-hero-title">
<span id="hero-count">95</span> systems shipped.
</h1>
<p class="lv-hero-sub">
This isn't a portfolio. It's a live feed — every deployment,
every fix, pulled in real time. Unedited. Unfakeable.
</p>
</div>
</section>
<!-- ═══ STATS STRIP ═══ -->
<section class="lv-strip">
<div class="container">
<div class="lv-strip-grid">
<div class="lv-strip-item">
<div class="lv-strip-ring lv-ring-green">
<svg viewBox="0 0 80 80"><circle cx="40" cy="40" r="36" class="lv-ring-bg"/><circle cx="40" cy="40" r="36" class="lv-ring-fg" id="ring-shipped"/></svg>
<span class="lv-ring-num" id="s-shipped"></span>
</div>
<div class="lv-strip-meta">
<span class="lv-strip-val" id="sv-shipped"></span>
<span class="lv-strip-label">In production</span>
</div>
</div>
<div class="lv-strip-item">
<div class="lv-strip-ring lv-ring-amber">
<svg viewBox="0 0 80 80"><circle cx="40" cy="40" r="36" class="lv-ring-bg"/><circle cx="40" cy="40" r="36" class="lv-ring-fg" id="ring-building"/></svg>
<span class="lv-ring-num" id="s-building"></span>
</div>
<div class="lv-strip-meta">
<span class="lv-strip-val" id="sv-building"></span>
<span class="lv-strip-label">Building now</span>
</div>
</div>
<div class="lv-strip-item">
<div class="lv-strip-ring lv-ring-red">
<svg viewBox="0 0 80 80"><circle cx="40" cy="40" r="36" class="lv-ring-bg"/><circle cx="40" cy="40" r="36" class="lv-ring-fg" id="ring-oops"/></svg>
<span class="lv-ring-num" id="s-oops"></span>
</div>
<div class="lv-strip-meta">
<span class="lv-strip-val" id="sv-oops"></span>
<span class="lv-strip-label">Incidents caught</span>
</div>
</div>
<div class="lv-strip-item">
<div class="lv-strip-icon"></div>
<div class="lv-strip-meta">
<span class="lv-strip-val">0 hrs</span>
<span class="lv-strip-label">Downtime</span>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ PROJECT CARDS ═══ -->
<section class="lv-projects">
<div class="container">
<h2 class="lv-section-title">Projects</h2>
<div class="lv-project-grid" id="project-grid">
<!-- rendered by JS -->
</div>
</div>
</section>
<!-- ═══ TIMELINE ═══ -->
<section class="lv-timeline-section">
<div class="container">
<div class="lv-timeline-head">
<h2 class="lv-section-title">Timeline</h2>
<div class="lv-filters" id="filters">
<button class="lv-pill active" data-filter="all">All</button>
</div>
</div>
<div class="lv-timeline" id="timeline">
<div class="lv-loading"><span class="lv-dot-pulse"></span> Connecting to database…</div>
</div>
</div>
</section>
<!-- ═══ INCIDENTS ═══ -->
<section class="lv-incidents">
<div class="container">
<div class="lv-incidents-head">
<div>
<h2 class="lv-section-title">Incident Log</h2>
<p class="lv-incidents-sub">Things that broke. How fast we fixed them.</p>
</div>
<div class="lv-incidents-stat">
<span class="lv-incidents-num" id="inc-avg"></span>
<span class="lv-incidents-label">avg fix time</span>
</div>
</div>
<div class="lv-incidents-list" id="oops-list">
<div class="lv-loading"><span class="lv-dot-pulse"></span> Loading…</div>
</div>
</div>
</section>
<!-- ═══ CTA ═══ -->
<section class="lv-cta">
<div class="container">
<div class="lv-cta-inner">
<div class="lv-cta-glow"></div>
<p class="lv-cta-kicker">This is what shipping looks like.</p>
<h2 class="lv-cta-title">Most agencies show a portfolio.<br><span class="accent">We show the live feed.</span></h2>
<a href="/#contact" class="btn btn-primary btn-large">Talk to us →</a>
</div>
</div>
</section>
<footer class="site-footer">
<div class="container">
<div class="footer-inner">
<div class="footer-brand">
<span class="footer-logo">QuikCue</span>
<span class="footer-tagline">We build the machine. You run the mission.</span>
</div>
<div class="footer-links">
<a href="/live">Live Feed</a>
<a href="https://git.quikcue.com" target="_blank">Git</a>
<a href="mailto:omair@quikcue.com">Contact</a>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 QuikCue Ltd. London, UK.</span>
<span class="footer-built">Built with obsessive velocity.</span>
</div>
</div>
</footer>
<script>
const API = 'https://calvana.quikcue.com/api';
// ══════════════════════════════════════════
// DISPLAY MAP — visitor-friendly
// ══════════════════════════════════════════
const DISPLAY={100:{p:'quikcue',t:'Live build log — UI overhaul and visitor-friendly entries'},99:{p:'quikcue',t:'Build log page added to quikcue.com',s:'Full shipping history with filters, stats, and incident log — all pulled live.'},98:{p:'charityright',t:'Automated milestone emails for 40,000+ supporters',s:'Recognises donation milestones (first gift through to £1,000) and sends personalised emails via Mailchimp — fully automated.'},97:{p:'quikcue',t:'QuikCue site deployed to production pipeline',s:'Source code version-controlled, automatic SSL across 3 domains.'},96:{p:'quikcue',t:'QuikCue company homepage launched',s:'Full company site with live engineering feed, client showcase, and dark premium design.'},95:{p:'pnpl',t:'Dashboard redesign — header, sidebar, and onboarding',s:'New layout with contextual upsell prompts and a lead capture flow.'},94:{p:'pnpl',t:'Embeddable mini widget for charity websites',s:'Compact 160px widget that expands on click — charities embed on any page.'},93:{p:'pnpl',t:'WhatsApp notification redesign + quick-add appeal picker'},92:{p:'pnpl',t:'Conditional and match funding pledges',s:'Supporters can pledge conditionally or match others — tracked across the entire product.'},91:{p:'charityright',t:'Seasonal giving data audit and dashboard overhaul',s:'Audited all giving data. Built seasonal view with Ramadan 2026 campaign performance.'},90:{p:'pnpl',t:'Complete redesign of fundraiser link creation',s:'3-step wizard: choose fundraiser → payment method → name your link.'},89:{p:'infra',t:'Git organisation cleanup across platforms'},88:{p:'pnpl',t:'Full-width landing page design across all dashboard pages'},87:{p:'charityright',t:'Seasonal giving dashboard with campaign analytics',s:'Per-campaign analytics, seasonal vs daily navigation, subscriber progress tracking.'},86:{p:'pnpl',t:'Education-first UX overhaul for automation and collection pages'},85:{p:'pnpl',t:'Every dashboard page redesigned as a landing page',s:'7 pages overhauled with brand photography and human language.'},84:{p:'charityright',t:'Full supporter care — refunds, cancellations, and edits',s:'Admins can refund, cancel, or edit any donation with Stripe sync.'},83:{p:'pnpl',t:'World-class WhatsApp message templates with A/B testing'},82:{p:'pnpl',t:'Universal payment call-to-action system',s:'Smart CTAs that adapt based on payment method.'},81:{p:'pnpl',t:'AI message generation with quality enforcement'},80:{p:'pnpl',t:'Brand photography integrated into the dashboard'},79:{p:'pnpl',t:'AI-powered message optimisation — fully automated',s:'One button starts AI optimisation. Generates challengers, tests, picks winners.'},78:{p:'pnpl',t:'Automation page rebuilt as a single conversation view',s:'Removed 15 UI panels. Now one WhatsApp conversation thread.'},77:{p:'pnpl',t:'Switched AI model — costs reduced to ~£0.08/1M messages'},76:{p:'pnpl',t:'AI provider switch to Gemini Flash (free tier)'},75:{p:'pnpl',t:'AI-native A/B testing — messages that improve themselves',s:'One-click AI challengers. Statistical winner detection. Continuous improvement.'},74:{p:'pnpl',t:'Message design studio with WhatsApp preview',s:'Phone mockup previews for WhatsApp, email, and SMS with A/B tracking.'},73:{p:'pnpl',t:'Multi-channel messaging engine — WhatsApp, SMS, email',s:'Waterfall delivery: WhatsApp → SMS → email. Channel status dashboard.'},72:{p:'pnpl',t:'Settings page redesigned as a guided checklist'},71:{p:'charityright',t:'Admin panel v4 — command centre redesign',s:'Sidebar reorganised. Every page uses tabs with live counts.'},70:{p:'pnpl',t:'Stripe integration — charities connect their own account',s:'Each charity pastes their Stripe key. Webhook auto-confirms payments.'},69:{p:'pnpl',t:'Removed 716 lines of dead payment code'},68:{p:'pnpl',t:'Settings page redesign with readiness tracking'},67:{p:'pnpl',t:'Team management with role-based access control'},66:{p:'pnpl',t:'Payment reconciliation embedded in the Money page'},65:{p:'pnpl',t:'Context-aware Money and Reports pages'},64:{p:'pnpl',t:'Fundraiser collection page — flattened, zero extra clicks'},63:{p:'charityright',t:'Admin panel v3 — persona-driven operational design',s:'Donor lookup in one search. Major donor identification. Payment investigation.'},62:{p:'charityright',t:'Admin panel v2 — AI-powered approval engine',s:'AI auto-approved 44% of stale reviews. Queue reduced from 151 to 84.'},61:{p:'pnpl',t:'Complete dashboard UX overhaul — welcome flow to sharing',s:'Signup to shareable link in 90 seconds.'},60:{p:'pnpl',t:'Smart timing, duplicate detection, and bank presets'},59:{p:'charityright',t:'Admin panel — full audit and major rebuild',s:'Dashboard widgets, global search, internal notes, sidebar navigation.'},58:{p:'pnpl',t:'36 product improvements deployed in one sprint'},57:{p:'pnpl',t:'Product gap analysis v2 — revised for WhatsApp-first reality'},56:{p:'pnpl',t:'Product gap analysis — landing pages vs actual product',s:'43 gaps identified. 28 hidden needs. 10 AI features specced.'},55:{p:'charityright',t:'Stacking card scroll effect on the marketing site'},53:{p:'charityright',t:'Persona section rebuilt with editorial photography grid'},52:{p:'charityright',t:'Fixed 7 conversion killers across in-app browsers',s:'Session handling, bot protection, 3D Secure, form UX — all fixed.'},51:{p:'charityright',t:'Hero image + full site image optimisation',s:'Images cut 85% (13.6MB → 2.1MB). 10x faster loads.'},50:{p:'charityright',t:'New cinematic hero section'},49:{p:'charityright',t:'Brand identity applied across every page'},48:{p:'charityright',t:'Complete brand identity system generated in 116 seconds',s:'1 brand doc + 32 visual assets via 8 parallel AI sessions.'},47:{p:'charityright',t:'AI-generated photography for all landing pages'},46:{p:'charityright',t:'All landing pages redesigned with sharp flat typography'},45:{p:'charityright',t:'4 persona landing pages + main page rewrite',s:'5 pages total — every section earns its place.'},44:{p:'charityright',t:'Bulletproof consent system — Gift Aid, email, WhatsApp',s:'HMRC-compliant declarations with immutable audit trail.'},43:{p:'pnpl',t:'Replaced internal abbreviations with full product name'},42:{p:'pnpl',t:'Simplified fund types + external payment tracking'},41:{p:'pnpl',t:'Terminology overhaul — clearer language throughout'},40:{p:'pnpl',t:'Zakat fund types and campaign fund allocation'},39:{p:'pnpl',t:'Fundraiser mode — role-aware onboarding',s:'2 user types, 5 platforms, dashboard never blocks access.'},38:{p:'pnpl',t:'Social login — Google, Apple, passwordless auth'},37:{p:'pnpl',t:'Onboarding cut from 8 screens to 2'},36:{p:'pnpl',t:'Demo login, admin view, and password recovery'},35:{p:'charityright',t:'Last 10 Nights campaign + Zakat toggle live'},31:{p:'pnpl',t:'Production launch — auth, multi-tenant, signup',s:'Auth + signup live. Dashboard protected. WhatsApp QR in settings.'},30:{p:'pnpl',t:'Landing page v2 with updated design language'},29:{p:'pnpl',t:'Automated reminder system + dashboard rebuild',s:'Reminder cron running every 15 minutes.'},28:{p:'pnpl',t:'Deferred payments and instalment plans',s:'A pledge is a promise to pay on a date. 212 month plans.'},27:{p:'pnpl',t:'Premium UI + AI messaging + WhatsApp integration',s:'Live at pledge.quikcue.com.'},26:{p:'charityright',t:'Match funding donation widget for Last 10 Nights'},25:{p:'pnpl',t:'Persona-driven features for UK charity market'},24:{p:'justvitamin',t:'Board decision summary layout fix'},23:{p:'pnpl',t:'Pledge Now Pay Later deployed to production',s:'Modern web stack with 10 database tables. Seeded with demo org.'},22:{p:'justvitamin',t:'Dynamic analytics dashboard with API-driven charts',s:'7 insights that recalculate per date range — all live data.'},21:{p:'justvitamin',t:'Conversion audit — 23 data claims verified',s:'Every number checked against raw database. 10 fixes applied.'},20:{p:'justvitamin',t:'AI image generation + product page builder',s:'3 live AI demos. Image-to-image from real product photos.'},19:{p:'justvitamin',t:'Analytics backend',s:'17 tables, 20,735 rows from 728K orders. Dashboard loads in 0.2 seconds.'},17:{p:'justvitamin',t:'Password-protected analytics site'},15:{p:'comms',t:'WhatsApp gateway upgraded — multi-session support',s:'Upgraded tier. Multiple WhatsApp sessions restored.'},14:{p:'comms',t:'Live chat system restored after restart loop'},13:{p:'infra',t:'Database-backed build log with live API',s:'This page. Database + API serving data in real time.'},12:{p:'infra',t:'Staging health check — WordPress + Laravel'},11:{p:'justvitamin',t:'Analytics app deployed to production'},10:{p:'infra',t:'Separate Git servers per organisation'},9:{p:'quikcue',t:'Calvana microsite launched'},8:{p:'quikcue',t:'QuikCue operations hub deployed'},7:{p:'comms',t:'Full communications stack deployed',s:'Live chat, workflow automation, and WhatsApp gateway.'},6:{p:'charityright',t:'Laravel admin staging environment'},5:{p:'charityright',t:'Analytics dashboard deployed'},4:{p:'charityright',t:'Checkout flow v2 deployed'},3:{p:'charityright',t:'WordPress marketing site (staging)'},2:{p:'charityright',t:'Data analytics dashboard with Metabase'},1:{p:'infra',t:'Full server migration — 14 databases, zero downtime',s:'Complete infrastructure migration. 14 databases moved with zero downtime.'}};
const OOPS_DISPLAY={19:{t:'Deployment script overwrote API server config',f:'Restored in 3 min'},18:{t:'Routing config couldn\'t reach application container',f:'Fixed in 15 min'},17:{t:'Database data lost during deployment path change',f:'Restored in 4 min'},16:{t:'Admin panel crashed after deploy — null references',f:'Fixed in 8 min'},15:{t:'Navigation group name collision in admin panel',f:'Fixed in 5 min'},14:{t:'Deploy script missed a required variable',f:'Fixed in 12 min'},13:{t:'Admin filter crashed before data was ready',f:'Fixed in 25 min'},12:{t:'Wrong database syntax + missing code import',f:'Fixed in 6 min'},11:{t:'Dashboard widgets invisible — registration issue',f:'Fixed in 5 min'},10:{t:'AI review failed — messages too long for column',f:'Fixed in 3 min'},9:{t:'Widget property incompatible with framework',f:'Fixed in 2 min'},8:{t:'Deploy script corrupted files with dollar signs',f:'Fixed in 8 min'},7:{t:'Product scraper broken — garbled server responses',f:'Fixed in 8 min'},6:{t:'Analytics claim unverifiable — 97.4% had no source',f:'Fixed in 5 min'},5:{t:'Email buttons had no email address',f:'Fixed in 3 min'},4:{t:'WhatsApp gateway only supported 1 session',f:'Fixed in 25 min'},3:{t:'Log file permissions broken after debugging',f:'Fixed'},2:{t:'App errors on every page — 59 pending DB updates',f:'Fixed'},1:{t:'WordPress hacked via malicious plugin',f:'Malware removed, vector blocked'}};
const PROJECTS={
charityright:{label:'CharityRight',color:'#3b82f6',icon:'💙',desc:'UK charity donation & operations platform'},
pnpl:{label:'Pledge Now Pay Later',color:'#8b5cf6',icon:'🤝',desc:'SaaS pledging product for charities'},
justvitamin:{label:'JustVitamin',color:'#f59e0b',icon:'💊',desc:'E-commerce analytics & AI product builder'},
infra:{label:'Infrastructure',color:'#6b7280',icon:'🔧',desc:'Servers, deployment, DevOps'},
comms:{label:'Communications',color:'#10b981',icon:'💬',desc:'WhatsApp, chat, email automation'},
quikcue:{label:'QuikCue',color:'#00ff9f',icon:'⚡',desc:'Company site & internal tools'},
};
function detectProject(title){const t=title.toLowerCase();if(t.includes('charityright')||t.includes('scheduled giving')||t.includes('supporter care')||t.includes('admin panel')||t.includes('admin v')||t.includes('gift aid')||t.includes('persona')||t.includes('consent'))return'charityright';if(t.includes('pnpl')||t.includes('pledge')||t.includes('collect')||t.includes('fundrais'))return'pnpl';if(t.includes('justvitamin'))return'justvitamin';if(t.includes('comms')||t.includes('chatwoot')||t.includes('waha'))return'comms';if(t.includes('git ')||t.includes('migration')||t.includes('staging')||t.includes('server'))return'infra';if(t.includes('quikcue')||t.includes('calvana')||t.includes('hub'))return'quikcue';return'infra';}
async function init(){
const[ships,oops,stats]=await Promise.all([
fetch(`${API}/ships`).then(r=>r.json()).catch(()=>[]),
fetch(`${API}/oops`).then(r=>r.json()).catch(()=>[]),
fetch(`${API}/stats`).then(r=>r.json()).catch(()=>({total:0,shipped:0,oops:0}))
]);
renderHero(stats);
renderStrip(stats,ships);
renderProjects(ships);
renderFilters(ships);
renderTimeline(ships);
renderOops(oops);
}
function renderHero(stats){
const el=document.getElementById('hero-count');
anim(el,stats.total||95,1200);
}
function renderStrip(stats,ships){
const total=stats.total||1;
const shipped=stats.shipped||0;
const building=ships.filter(s=>s.status==='shipping').length;
const oops=stats.oops||0;
setRing('ring-shipped',shipped/total);
setRing('ring-building',building/total);
setRing('ring-oops',oops/total);
setText('sv-shipped',shipped);setText('s-shipped',shipped);
setText('sv-building',building);setText('s-building',building);
setText('sv-oops',oops);setText('s-oops',oops);
}
function setRing(id,pct){
const el=document.getElementById(id);if(!el)return;
const circ=2*Math.PI*36;
el.style.strokeDasharray=circ;
el.style.strokeDashoffset=circ*(1-Math.min(pct,1));
}
function setText(id,v){const el=document.getElementById(id);if(el)el.textContent=v;}
function renderProjects(ships){
const counts={};
ships.forEach(s=>{const d=DISPLAY[s.id];const p=d?d.p:detectProject(s.title);counts[p]=(counts[p]||0)+1;});
const grid=document.getElementById('project-grid');
grid.innerHTML=Object.entries(PROJECTS).map(([key,proj])=>{
const c=counts[key]||0;if(!c)return'';
return`<button class="lv-pcard" data-project="${key}" onclick="filterTo('${key}')">
<div class="lv-pcard-icon">${proj.icon}</div>
<div class="lv-pcard-info">
<span class="lv-pcard-name">${proj.label}</span>
<span class="lv-pcard-desc">${proj.desc}</span>
</div>
<span class="lv-pcard-count" style="color:${proj.color}">${c}</span>
</button>`;
}).join('');
}
function renderFilters(ships){
const counts={};
ships.forEach(s=>{const d=DISPLAY[s.id];const p=d?d.p:detectProject(s.title);counts[p]=(counts[p]||0)+1;});
const container=document.getElementById('filters');
['charityright','pnpl','justvitamin','comms','infra','quikcue'].forEach(key=>{
if(!counts[key])return;
const p=PROJECTS[key];
const btn=document.createElement('button');
btn.className='lv-pill';btn.dataset.filter=key;
btn.innerHTML=`<span class="lv-pill-dot" style="background:${p.color}"></span>${p.label}`;
btn.onclick=()=>filterTo(key);
container.appendChild(btn);
});
}
function filterTo(key){
document.querySelectorAll('.lv-pill').forEach(b=>{b.classList.toggle('active',b.dataset.filter===key);});
document.querySelectorAll('.lv-pill[data-filter="all"]').forEach(b=>{b.classList.toggle('active',key==='all');});
document.querySelectorAll('.lv-tl-card').forEach(c=>{c.style.display=(key==='all'||c.dataset.project===key)?'':'none';});
document.querySelectorAll('.lv-tl-day').forEach(d=>{d.style.display=d.querySelectorAll('.lv-tl-card:not([style*="display: none"])').length?'':'none';});
document.querySelectorAll('.lv-pcard').forEach(c=>{c.classList.toggle('lv-pcard-active',c.dataset.project===key);});
if(key==='all')document.querySelectorAll('.lv-pcard').forEach(c=>c.classList.remove('lv-pcard-active'));
}
function renderTimeline(ships){
const container=document.getElementById('timeline');
const groups={};
ships.forEach(s=>{const d=(s.created_at||'').split('T')[0]||'Unknown';if(!groups[d])groups[d]=[];groups[d].push(s);});
let html='';
for(const[date,entries]of Object.entries(groups)){
html+=`<div class="lv-tl-day"><div class="lv-tl-date"><span>${fmtDate(date)}</span><span class="lv-tl-date-count">${entries.length}</span></div>`;
entries.forEach((s,i)=>{
const d=DISPLAY[s.id]||{};
const proj=d.p||detectProject(s.title);
const p=PROJECTS[proj]||PROJECTS.infra;
const title=d.t||s.title;
const summary=d.s||'';
const isActive=s.status==='shipping';
const featured=!!summary&&i<3;
html+=`<div class="lv-tl-card ${featured?'lv-tl-featured':''} ${isActive?'lv-tl-active':''}" data-project="${proj}">
<div class="lv-tl-line" style="--line-color:${p.color}"></div>
<div class="lv-tl-content">
<div class="lv-tl-top">
<span class="lv-tl-tag" style="--c:${p.color}">${p.icon} ${p.label}</span>
${isActive?'<span class="lv-tl-building">● Building</span>':''}
</div>
<h3 class="lv-tl-title">${esc(title)}</h3>
${summary?`<p class="lv-tl-summary">${esc(summary)}</p>`:''}
</div>
</div>`;
});
html+='</div>';
}
container.innerHTML=html||'<div class="lv-loading">No entries found.</div>';
}
function renderOops(oops){
const container=document.getElementById('oops-list');
if(!oops.length){container.innerHTML='<p class="lv-empty">Nothing broken yet.</p>';return;}
// calc avg fix time
let total=0,count=0;
oops.forEach(o=>{const d=OOPS_DISPLAY[o.id];const f=d?d.f:o.fix_time;const m=f?f.match(/(\d+)\s*min/):null;if(m){total+=parseInt(m[1]);count++;}});
const avg=count?Math.round(total/count):0;
if(avg)document.getElementById('inc-avg').textContent=avg+' min';
container.innerHTML=oops.map(o=>{
const d=OOPS_DISPLAY[o.id]||{};
return`<div class="lv-inc-card">
<div class="lv-inc-dot"></div>
<div class="lv-inc-body">
<span class="lv-inc-what">${esc(d.t||o.description)}</span>
${d.f?`<span class="lv-inc-fix">${esc(d.f)}</span>`:''}
</div>
</div>`;
}).join('');
}
function fmtDate(str){try{const d=new Date(str+'T00:00:00');return d.toLocaleDateString('en-GB',{weekday:'short',day:'numeric',month:'short',year:'numeric'});}catch{return str;}}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function anim(el,target,dur){if(!el)return;const t0=performance.now();(function f(now){const p=Math.min((now-t0)/dur,1);el.textContent=Math.floor(target*(1-Math.pow(1-p,3)));if(p<1)requestAnimationFrame(f);})(t0);}
document.querySelector('.lv-pill[data-filter="all"]').onclick=()=>filterTo('all');
init();
</script>
</body>
</html>