431 lines
30 KiB
HTML
431 lines
30 KiB
HTML
<!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">
|
||
<style>
|
||
.lv-team-filter { padding: 0.5rem 0 1rem; }
|
||
.lv-team-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||
.lv-team-pill {
|
||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||
padding: 0.4rem 1rem; border-radius: 999px; border: 1px solid rgba(255,255,255,0.1);
|
||
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.6);
|
||
font-size: 0.85rem; cursor: pointer; transition: all 0.2s;
|
||
font-family: 'Inter', sans-serif;
|
||
}
|
||
.lv-team-pill:hover { background: rgba(255,255,255,0.08); color: #fff; }
|
||
.lv-team-pill.active { background: rgba(0,255,159,0.12); border-color: rgba(0,255,159,0.3); color: #00ff9f; }
|
||
.lv-team-pill .lv-team-count {
|
||
background: rgba(255,255,255,0.1); padding: 0.1rem 0.4rem; border-radius: 999px;
|
||
font-size: 0.75rem; font-weight: 600;
|
||
}
|
||
.lv-team-pill.active .lv-team-count { background: rgba(0,255,159,0.2); }
|
||
#timeline, #oops-list { transition: opacity 0.15s ease; }
|
||
</style>
|
||
</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>
|
||
|
||
<!-- ═══ TEAM FILTER ═══ -->
|
||
<section class="lv-team-filter">
|
||
<div class="container">
|
||
<div class="lv-team-bar" id="team-bar">
|
||
<button class="lv-team-pill active" data-member="all" onclick="filterByMember('all')">
|
||
👥 Everyone
|
||
</button>
|
||
<!-- JS will add team member buttons here -->
|
||
</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 = '/api';
|
||
let activeMember = 'all';
|
||
|
||
// ══════════════════════════════════════════
|
||
// DISPLAY MAP — visitor-friendly
|
||
// ══════════════════════════════════════════
|
||
const DISPLAY={230:{p:'quikcue',t:'Live build log hardening — security, data integrity, and UX polish',s:'25-point audit: SQL injection protection, API auth, XSS prevention, filter bug fixes, loading states, and dead code cleanup.'},229:{p:'quikcue',t:'Live build log polish — team filtering, project discovery, and onboarding',s:'Team member filter bar, capitalised names, project auto-detection, and system prompt guidance for consistent entries.'},228:{p:'quikcue',t:'Team activity tracking on the live build log',s:'Each team member\'s work is now individually tracked and filterable. Auto-detects who you are from your machine.'},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. 2–12 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:'QuikCue live feed 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('charity right')||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')||t.includes('donation')||t.includes('donor')||t.includes('checkout'))return'charityright';if(t.includes('pnpl')||t.includes('pledge')||t.includes('collect')||t.includes('fundrais')||t.includes('fundraiser')||t.includes('pay later')||t.includes('instalment'))return'pnpl';if(t.includes('justvitamin')||t.includes('just vitamin'))return'justvitamin';if(t.includes('comms')||t.includes('chatwoot')||t.includes('waha')||t.includes('whatsapp gateway')||t.includes('communication'))return'comms';if(t.includes('git ')||t.includes('migration')||t.includes('staging')||t.includes('server')||t.includes('docker')||t.includes('deploy')||t.includes('dokploy')||t.includes('infrastructure'))return'infra';if(t.includes('quikcue')||t.includes('live feed')||t.includes('hub')||t.includes('build log')||t.includes('ship log'))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);
|
||
renderTeamBar(stats);
|
||
}
|
||
|
||
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');
|
||
// Remove old project pills (keep the "All" button)
|
||
container.querySelectorAll('.lv-pill:not([data-filter="all"])').forEach(b=>b.remove());
|
||
['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 filterByMember(member) {
|
||
activeMember = member;
|
||
document.querySelectorAll('.lv-team-pill').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.member === member);
|
||
});
|
||
// Show loading state
|
||
document.getElementById('timeline').style.opacity = '0.4';
|
||
document.getElementById('oops-list').style.opacity = '0.4';
|
||
// Re-fetch filtered data + always fetch unfiltered stats for hero/team bar
|
||
const qs = member === 'all' ? '' : `?member=${encodeURIComponent(member)}`;
|
||
Promise.all([
|
||
fetch(`${API}/ships${qs}`).then(r => r.json()).catch(() => []),
|
||
fetch(`${API}/oops${qs}`).then(r => r.json()).catch(() => []),
|
||
fetch(`${API}/stats${qs}`).then(r => r.json()).catch(() => ({ total: 0, shipped: 0, oops: 0 })),
|
||
fetch(`${API}/stats`).then(r => r.json()).catch(() => null)
|
||
]).then(([ships, oops, filteredStats, globalStats]) => {
|
||
// Hero always shows total (unfiltered) count
|
||
if (globalStats) renderHero(globalStats);
|
||
renderStrip(filteredStats, ships);
|
||
renderProjects(ships);
|
||
renderFilters(ships);
|
||
renderTimeline(ships);
|
||
renderOops(oops);
|
||
// Team bar always uses global stats for member counts
|
||
renderTeamBar(globalStats || filteredStats);
|
||
// Reset project filter to "all" when switching members
|
||
filterTo('all');
|
||
// Remove loading state
|
||
document.getElementById('timeline').style.opacity = '1';
|
||
document.getElementById('oops-list').style.opacity = '1';
|
||
});
|
||
}
|
||
|
||
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 titleCase(s){return s.charAt(0).toUpperCase()+s.slice(1);}
|
||
|
||
function renderTeamBar(stats) {
|
||
const bar = document.getElementById('team-bar');
|
||
const section = bar.closest('.lv-team-filter');
|
||
if (!stats.members || !stats.members.length) { if(section) section.style.display='none'; return; }
|
||
// Hide team bar when only 1 member — it's redundant
|
||
if (stats.members.length <= 1) { if(section) section.style.display='none'; return; }
|
||
if(section) section.style.display='';
|
||
// Clear existing member pills (keep the "Everyone" button)
|
||
const allBtn = bar.querySelector('[data-member="all"]');
|
||
bar.innerHTML = '';
|
||
if (allBtn) {
|
||
if (activeMember === 'all') allBtn.classList.add('active'); else allBtn.classList.remove('active');
|
||
bar.appendChild(allBtn);
|
||
}
|
||
|
||
stats.members.forEach(m => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'lv-team-pill';
|
||
if (m.member === activeMember) btn.classList.add('active');
|
||
btn.dataset.member = m.member;
|
||
btn.innerHTML = `${esc(titleCase(m.member))} <span class="lv-team-count">${esc(String(m.count))}</span>`;
|
||
btn.onclick = () => filterByMember(m.member);
|
||
bar.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
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=p>=1?target: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>
|