feat: /live overhaul — visitor-friendly titles, project filters, incident log cleanup
This commit is contained in:
+192
-88
@@ -842,199 +842,295 @@ nav {
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
/LIVE PAGE — Full Shipping Log
|
||||
/LIVE PAGE — Build Log
|
||||
═══════════════════════════════════════════ */
|
||||
.live-page {
|
||||
padding-top: 96px;
|
||||
padding-bottom: 5rem;
|
||||
padding-bottom: 6rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.live-page-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.live-page-eyebrow {
|
||||
|
||||
/* Header */
|
||||
.lp-header { margin-bottom: 3rem; }
|
||||
.lp-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.14em;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.live-page-title {
|
||||
.lp-title {
|
||||
font-family: var(--sans);
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
font-size: clamp(2.2rem, 5vw, 3.6rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.08;
|
||||
color: var(--text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.live-page-sub {
|
||||
.lp-sub {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.75;
|
||||
max-width: 600px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.lp-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.lp-stat {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.lp-stat-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.lp-stat-green { color: var(--accent); }
|
||||
.lp-stat-amber { color: var(--amber); }
|
||||
.lp-stat-red { color: var(--red); }
|
||||
.lp-stat-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.log-filters {
|
||||
.lp-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 2.5rem 0 2rem;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 2.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-btn {
|
||||
.lp-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.45rem 1rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
.lp-filter:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: var(--accent);
|
||||
color: #050505;
|
||||
border-color: var(--accent);
|
||||
.lp-filter.active {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
.lp-filter-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lp-filter-count {
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Day groups */
|
||||
.log-day {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.log-day-header {
|
||||
/* Day group */
|
||||
.lp-day { margin-bottom: 2.5rem; }
|
||||
.lp-day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.log-day-date {
|
||||
.lp-day-date {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.log-day-count {
|
||||
.lp-day-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-card);
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
/* Log entries */
|
||||
.log-entry {
|
||||
padding: 0.85rem 1.25rem;
|
||||
/* Ship card */
|
||||
.lp-card {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.log-entry:hover {
|
||||
.lp-card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.log-entry-head {
|
||||
.lp-card-active {
|
||||
border-left: 3px solid var(--amber);
|
||||
}
|
||||
.lp-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.log-entry-id {
|
||||
.lp-card-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
min-width: 2.2rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--tag-color, var(--text-muted));
|
||||
background: color-mix(in srgb, var(--tag-color, #666) 12%, transparent);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-entry-title {
|
||||
.lp-card-title {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.log-metric {
|
||||
.lp-card-status {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--amber);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
animation: pulse-text 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-text {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.lp-card-summary {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
line-height: 1.6;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Oops section */
|
||||
.oops-section {
|
||||
.lp-oops {
|
||||
margin-top: 4rem;
|
||||
padding-top: 3rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.oops-title {
|
||||
.lp-oops-header { margin-bottom: 1.5rem; }
|
||||
.lp-oops-title {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.35rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.oops-sub {
|
||||
.lp-oops-sub {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.oops-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.75rem 1rem;
|
||||
.lp-oops-card {
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--red);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #ef4444;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.oops-id {
|
||||
.lp-oops-what {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.lp-oops-fix {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.lp-oops-empty {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Live page CTA */
|
||||
.live-page-cta {
|
||||
/* Bottom CTA */
|
||||
.lp-bottom-cta {
|
||||
text-align: center;
|
||||
margin-top: 5rem;
|
||||
padding-top: 3rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.live-page-cta p {
|
||||
.lp-bottom-line {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.lp-bottom-sub {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.log-loading {
|
||||
/* Loading */
|
||||
.lp-loading {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lp-loading-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: pulse-text 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Active nav link */
|
||||
@@ -1043,11 +1139,19 @@ nav {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
/* ── Responsive: /live page ── */
|
||||
/* ── Responsive: /live ── */
|
||||
@media (max-width: 900px) {
|
||||
.lp-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.live-page { padding-top: 80px; }
|
||||
.log-entry-head { gap: 0.4rem; }
|
||||
.log-entry-id { display: none; }
|
||||
.log-filters { flex-wrap: wrap; }
|
||||
.oops-entry { flex-direction: column; gap: 0.25rem; }
|
||||
.lp-stats { gap: 0.5rem; }
|
||||
.lp-stat { padding: 1rem 0.5rem; }
|
||||
.lp-stat-num { font-size: 1.6rem; }
|
||||
.lp-filters { gap: 0.25rem; }
|
||||
.lp-filter { font-size: 0.65rem; padding: 0.35rem 0.6rem; }
|
||||
.lp-card-row { gap: 0.4rem; }
|
||||
.lp-card-tag { font-size: 0.58rem; }
|
||||
.lp-card-title { font-size: 0.82rem; }
|
||||
.lp-oops-card { padding: 0.75rem 0.85rem; }
|
||||
}
|
||||
|
||||
+341
-174
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>QuikCue — Live Shipping Log</title>
|
||||
<meta name="description" content="Every deployment, every fix, every system — logged and live. Real-time engineering feed from QuikCue.">
|
||||
<meta property="og:title" content="QuikCue — Live Shipping Log">
|
||||
<meta property="og:description" content="Real-time engineering feed. Every deployment logged. Full transparency.">
|
||||
<title>QuikCue — Live Build Log</title>
|
||||
<meta name="description" content="100 systems shipped. Every deployment, every fix — logged live from production. See what we've built for CharityRight, Pledge Now Pay Later, JustVitamin, and QuikCue infrastructure.">
|
||||
<meta property="og:title" content="QuikCue — Live Build Log">
|
||||
<meta property="og:description" content="100 systems shipped. Real-time production log. Full transparency.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://quikcue.com/live">
|
||||
<link rel="canonical" href="https://quikcue.com/live">
|
||||
@@ -32,63 +32,64 @@
|
||||
<div class="container">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="live-page-header">
|
||||
<div class="live-page-eyebrow">
|
||||
<header class="lp-header">
|
||||
<div class="lp-eyebrow">
|
||||
<div class="live-pulse"></div>
|
||||
<span>Live Engineering Feed</span>
|
||||
<span>Live from production</span>
|
||||
</div>
|
||||
<h1 class="live-page-title">Full Shipping Log</h1>
|
||||
<p class="live-page-sub">
|
||||
Every system we've shipped. Every fix we've made. Unfakeable, unedited, pulled
|
||||
straight from our PostgreSQL database in real time.
|
||||
<h1 class="lp-title">Build Log</h1>
|
||||
<p class="lp-sub">
|
||||
Every system we've deployed. Every bug we've caught. Pulled live from our
|
||||
production database — unedited, unfakeable.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="live-stats" id="stats-bar">
|
||||
<div class="live-stat">
|
||||
<div class="live-stat-num" id="stat-total">—</div>
|
||||
<div class="live-stat-label">Total ships</div>
|
||||
<!-- Stats row -->
|
||||
<div class="lp-stats" id="stats-bar">
|
||||
<div class="lp-stat">
|
||||
<div class="lp-stat-num" id="stat-total">—</div>
|
||||
<div class="lp-stat-label">Systems shipped</div>
|
||||
</div>
|
||||
<div class="live-stat">
|
||||
<div class="live-stat-num live-stat-green" id="stat-shipped">—</div>
|
||||
<div class="live-stat-label">Shipped</div>
|
||||
<div class="lp-stat">
|
||||
<div class="lp-stat-num lp-stat-green" id="stat-shipped">—</div>
|
||||
<div class="lp-stat-label">Live in production</div>
|
||||
</div>
|
||||
<div class="live-stat">
|
||||
<div class="live-stat-num live-stat-amber" id="stat-shipping">—</div>
|
||||
<div class="live-stat-label">In flight</div>
|
||||
<div class="lp-stat">
|
||||
<div class="lp-stat-num lp-stat-amber" id="stat-shipping">—</div>
|
||||
<div class="lp-stat-label">Building now</div>
|
||||
</div>
|
||||
<div class="live-stat">
|
||||
<div class="live-stat-num live-stat-red" id="stat-oops">—</div>
|
||||
<div class="live-stat-label">Oops</div>
|
||||
<div class="lp-stat">
|
||||
<div class="lp-stat-num lp-stat-red" id="stat-oops">—</div>
|
||||
<div class="lp-stat-label">Incidents caught</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="log-filters">
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="shipped">Shipped</button>
|
||||
<button class="filter-btn" data-filter="shipping">In flight</button>
|
||||
<!-- Project filters -->
|
||||
<div class="lp-filters" id="filters">
|
||||
<button class="lp-filter active" data-filter="all">All projects</button>
|
||||
</div>
|
||||
|
||||
<!-- Full Ship Log -->
|
||||
<div class="log-list" id="log-list">
|
||||
<div class="log-loading">Loading from database…</div>
|
||||
<!-- Ship log -->
|
||||
<div class="lp-log" id="log-list">
|
||||
<div class="lp-loading"><span class="lp-loading-dot"></span> Loading from database…</div>
|
||||
</div>
|
||||
|
||||
<!-- Oops Log -->
|
||||
<section class="oops-section" id="oops-section">
|
||||
<h2 class="oops-title">Oops Log</h2>
|
||||
<p class="oops-sub">If it's not here, we haven't broken it yet.</p>
|
||||
<div class="oops-list" id="oops-list">
|
||||
<div class="log-loading">Loading…</div>
|
||||
<!-- Oops section -->
|
||||
<section class="lp-oops" id="oops-section">
|
||||
<div class="lp-oops-header">
|
||||
<h2 class="lp-oops-title">Incident Log</h2>
|
||||
<p class="lp-oops-sub">Things that broke. How fast we fixed them. Full transparency.</p>
|
||||
</div>
|
||||
<div class="lp-oops-list" id="oops-list">
|
||||
<div class="lp-loading"><span class="lp-loading-dot"></span> Loading…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="live-page-cta">
|
||||
<p>This is what autonomous operations looks like.</p>
|
||||
<a href="/#contact" class="btn btn-primary">Start a conversation →</a>
|
||||
<div class="lp-bottom-cta">
|
||||
<p class="lp-bottom-line">This is what shipping looks like.</p>
|
||||
<p class="lp-bottom-sub">Most agencies show you a portfolio. We show you the production database.</p>
|
||||
<a href="/#contact" class="btn btn-primary btn-large">Talk to us →</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -114,150 +115,316 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const API = 'https://calvana.quikcue.com/api';
|
||||
<script>
|
||||
const API = 'https://calvana.quikcue.com/api';
|
||||
|
||||
// ── Load everything ──
|
||||
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 }))
|
||||
]);
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// VISITOR-FRIENDLY TRANSLATIONS
|
||||
// Every entry gets: project tag, clean title, optional summary
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const DISPLAY = {
|
||||
100: { project: 'quikcue', title: 'Live build log — UI overhaul and visitor-friendly entries' },
|
||||
99: { project: 'quikcue', title: 'Build log page added to quikcue.com', summary: 'Full shipping history with filters, stats, and incident log — all pulled live from the production database.' },
|
||||
98: { project: 'charityright', title: 'Automated milestone emails for 40,000+ supporters', summary: 'Recognises donation milestones (first gift through to £1,000) and sends personalised emails via Mailchimp — fully automated.' },
|
||||
97: { project: 'quikcue', title: 'QuikCue site deployed to Git + production pipeline', summary: 'Source code on Gitea, Docker Swarm service with automatic SSL across 3 domains.' },
|
||||
96: { project: 'quikcue', title: 'QuikCue company homepage launched', summary: 'Full company site: hero, three pillars, live engineering feed, client showcase. Dark premium design.' },
|
||||
95: { project: 'pnpl', title: 'Dashboard redesign — header, sidebar, and onboarding', summary: 'New layout with contextual upsell prompts and a lead capture flow with qualification fields.' },
|
||||
94: { project: 'pnpl', title: 'Embeddable mini widget for charity websites', summary: 'Compact 160px widget that expands on click. Charities can embed it on any page.' },
|
||||
93: { project: 'pnpl', title: 'WhatsApp notification redesign + quick-add appeal picker', summary: 'Redesigned notification bar. Multi-appeal organisations now see a visual picker for adding appeals.' },
|
||||
92: { project: 'pnpl', title: 'Conditional and match funding pledges', summary: 'Supporters can pledge conditionally ("I\'ll give if 100 others do") or match others. Tracked across the entire product — dashboard, reports, and donor-facing views.' },
|
||||
91: { project: 'charityright', title: 'Seasonal giving data audit and dashboard overhaul', summary: 'Audited all giving data. Built seasonal view showing Ramadan 2026 campaign performance vs. all-time.' },
|
||||
90: { project: 'pnpl', title: 'Complete redesign of the fundraiser link creation flow', summary: '3-step wizard: choose fundraiser → payment method → name your link. Payment method visible on every link card. Embed widget as sharing option.' },
|
||||
89: { project: 'infra', title: 'Git organisation cleanup across platforms', summary: 'Separated repos: CharityRight on GitLab, QuikCue on Gitea. Cleaned up duplicates.' },
|
||||
88: { project: 'pnpl', title: 'Full-width landing page design across all dashboard pages', summary: 'Every page (Home, Money, Reports, Settings) now uses edge-to-edge layout with no dead space.' },
|
||||
87: { project: 'charityright', title: 'Seasonal giving dashboard with campaign analytics', summary: 'Per-campaign analytics, navigation split between seasonal campaigns and daily giving, subscriber progress tracking.' },
|
||||
86: { project: 'pnpl', title: 'Education-first UX overhaul — automation and collection pages', summary: 'Two-column layouts with step-by-step guides, platform clarity indicators, and dual education panels.' },
|
||||
85: { project: 'pnpl', title: 'Every page redesigned as a landing page', summary: '7 pages overhauled with brand photography, dark panels, editorial sidebar. All technical language replaced with human language.' },
|
||||
84: { project: 'charityright', title: 'Full supporter care — refunds, cancellations, and edits', summary: 'Admins can now refund, cancel, or edit any donation or recurring gift with Stripe sync.' },
|
||||
83: { project: 'pnpl', title: 'World-class WhatsApp message templates with A/B testing', summary: 'Professional receipt and reminder templates. A/B test variants automatically. Winners promote themselves.' },
|
||||
82: { project: 'pnpl', title: 'Universal payment call-to-action system', summary: 'Smart CTAs that adapt based on payment method — bank transfer, card, direct debit, or external platform.' },
|
||||
81: { project: 'pnpl', title: 'AI message generation — enforced quality and style matching', summary: 'AI generates messages that match each charity\'s tone. Validates required info per message type. Regenerate with one click.' },
|
||||
80: { project: 'pnpl', title: 'Brand photography integrated into the dashboard', summary: 'Surgical image placement during onboarding — compacts once the charity is set up.' },
|
||||
79: { project: 'pnpl', title: 'AI-powered message optimisation — fully automated', summary: 'One button starts AI optimisation. Generates challenger messages, runs A/B tests, picks winners automatically.' },
|
||||
78: { project: 'pnpl', title: 'Automation page rebuilt as a single conversation view', summary: 'Removed 15 UI panels. The page is now one WhatsApp conversation thread — click to edit, hover for AI suggestions.' },
|
||||
77: { project: 'pnpl', title: 'Switched AI model to reduce costs to ~£0.08 per 1M messages' },
|
||||
76: { project: 'pnpl', title: 'AI provider switch to Gemini Flash (free tier)' },
|
||||
75: { project: 'pnpl', title: 'AI-native A/B testing — messages that improve themselves', summary: 'One-click AI challengers across 8 strategies. Statistical winner detection. Continuous improvement loop.' },
|
||||
74: { project: 'pnpl', title: 'Message design studio with WhatsApp preview', summary: 'Phone mockup previews for WhatsApp, email, and SMS. Edit templates per step. Built-in A/B testing with conversion tracking.' },
|
||||
73: { project: 'pnpl', title: 'Multi-channel messaging engine — WhatsApp, SMS, and email', summary: 'Waterfall delivery: tries WhatsApp first, falls back to SMS, then email. Channel status dashboard and message feed.' },
|
||||
72: { project: 'pnpl', title: 'Settings page redesigned as a guided checklist', summary: 'Step-by-step setup: completed items collapse to one-liners, uncompleted items stay expanded. Progress bar tracks readiness.' },
|
||||
71: { project: 'charityright', title: 'Admin panel v4 — command centre redesign', summary: 'Sidebar reorganised into 3 tiers. Every list page now uses tabs with live counts instead of hidden filter drawers.' },
|
||||
70: { project: 'pnpl', title: 'Stripe integration — charities connect their own account', summary: 'Each charity pastes their Stripe key. Donors see card payment option. Webhook auto-confirms payments.' },
|
||||
69: { project: 'pnpl', title: 'Removed 716 lines of dead payment code' },
|
||||
68: { project: 'pnpl', title: 'Settings page redesign with readiness tracking', summary: 'Dark hero with readiness bar, donor bank details preview, brand preview, feature discovery grid.' },
|
||||
67: { project: 'pnpl', title: 'Team management with role-based access control', summary: 'Community Leader role added. Permission matrix across all write actions. Login redirects by role.' },
|
||||
66: { project: 'pnpl', title: 'Payment reconciliation embedded directly in the Money page', summary: 'Upload and confirm payments without leaving the page. Zero extra clicks for the most critical action.' },
|
||||
65: { project: 'pnpl', title: 'Context-aware Money and Reports pages', summary: 'Money page shows confirm/nudge sections based on what needs attention. Reports show Gift Aid reclaimable amounts.' },
|
||||
64: { project: 'pnpl', title: 'Fundraiser collection page — flattened, zero extra clicks', summary: 'Share any link instantly without navigating into sub-pages. Inline link creation with embedded leaderboard.' },
|
||||
63: { project: 'charityright', title: 'Admin panel v3 — persona-driven operational design', summary: 'Donor lookup in one search. Fundraiser nurture queue with priority sorting. Major donor identification. Donation investigation by payment reference.' },
|
||||
62: { project: 'charityright', title: 'Admin panel v2 — AI-powered approval engine', summary: 'AI auto-approved 44% of stale review items. Queue reduced from 151 to 84 in one click.' },
|
||||
61: { project: 'pnpl', title: 'Complete dashboard UX overhaul — welcome flow to sharing', summary: 'New charities go from signup to shareable link in 90 seconds. Context-aware dashboard with 5 different states.' },
|
||||
60: { project: 'pnpl', title: 'Smart timing, duplicate detection, and bank presets shipped' },
|
||||
59: { project: 'charityright', title: 'Admin panel — full audit and major rebuild', summary: '4 dashboard widgets, sidebar navigation, global search, internal notes system. All deprecated code removed.' },
|
||||
58: { project: 'pnpl', title: '36 product improvements deployed in one sprint', summary: 'All pages returning 200, all API endpoints live at pledge.quikcue.com.' },
|
||||
57: { project: 'pnpl', title: 'Product gap analysis v2 — revised for WhatsApp-first reality', summary: '43 gaps re-evaluated, 8 downgraded (WhatsApp handles natively), 3 new gaps identified.' },
|
||||
56: { project: 'pnpl', title: 'Product gap analysis — landing pages vs. actual product', summary: '43 gaps identified across 5 landing pages. 28 hidden needs catalogued. 10 AI features specced.' },
|
||||
55: { project: 'charityright', title: 'Stacking card scroll effect on the marketing site', summary: 'CSS sticky cards that overlap as you scroll. Dark dinner photography regenerated to match brand.' },
|
||||
53: { project: 'charityright', title: 'Persona section rebuilt with editorial photography grid', summary: 'Sticky stacking cards, shadow depth, scenario-based personas with cinematic photography.' },
|
||||
52: { project: 'charityright', title: 'Fixed 7 conversion killers across in-app browsers', summary: 'Session handling, bot protection, 3D Secure payments, and form UX — all fixed across 13 different in-app browsers.' },
|
||||
51: { project: 'charityright', title: 'Hero image + full site image optimisation', summary: 'Total image size cut 85% (13.6MB → 2.1MB). WebP format. 10x faster page loads.' },
|
||||
50: { project: 'charityright', title: 'New cinematic hero section — typography + documentary photo', summary: '3 sections consolidated into 1 cinematic hero with stat strip and brand signature patterns.' },
|
||||
49: { project: 'charityright', title: 'Brand identity applied across every page', summary: '52 brand assets in the codebase. Single brand document as source of truth. Zero violations.' },
|
||||
48: { project: 'charityright', title: 'Complete brand identity system generated in 116 seconds', summary: '1 brand identity document + 32 visual assets. Generated using 8 parallel AI sessions.' },
|
||||
47: { project: 'charityright', title: 'AI-generated photography for all landing pages', summary: '20 AI-generated images across 5 pages. Zero placeholder images remaining.' },
|
||||
46: { project: 'charityright', title: 'All landing pages redesigned — sharp flat typography', summary: '5 pages redesigned with typography-driven layout. "Donors" renamed to "Organisations" throughout.' },
|
||||
45: { project: 'charityright', title: '4 persona landing pages + main page rewrite', summary: '5 pages total: 1 rewritten main + 4 new pages targeting specific audience types. Every section earns its place.' },
|
||||
44: { project: 'charityright', title: 'Bulletproof consent system — Gift Aid, email, and WhatsApp', summary: 'HMRC-compliant Gift Aid declarations. Email and WhatsApp opt-in with immutable audit trail. Messages only send if opted in.' },
|
||||
43: { project: 'pnpl', title: 'Replaced internal abbreviations with full product name' },
|
||||
42: { project: 'pnpl', title: 'Simplified fund types + external payment tracking', summary: '5 fund types simplified to 1 toggle. Added "I\'ve Donated" button for external platform pledges.' },
|
||||
41: { project: 'pnpl', title: 'Terminology overhaul — clearer language throughout', summary: 'Events → Campaigns, QR codes → Pledge Links. Sharing via WhatsApp, email, social, and QR.' },
|
||||
40: { project: 'pnpl', title: 'Zakat fund types and campaign fund allocation' },
|
||||
39: { project: 'pnpl', title: 'Fundraiser mode — role-aware onboarding for external platforms', summary: '2 user types, 5 platform integrations, role-aware signup. Dashboard never blocks access.' },
|
||||
38: { project: 'pnpl', title: 'Social login — Google, Apple, and passwordless authentication', summary: 'Auth0 integration with auto-provisioning on first social login.' },
|
||||
37: { project: 'pnpl', title: 'Onboarding cut from 8 screens to 2', summary: 'Signup simplified to 1 screen → instant dashboard. Onboarding checklist replaces the old wizard.' },
|
||||
36: { project: 'pnpl', title: 'Demo login, admin view, and password recovery' },
|
||||
35: { project: 'charityright', title: 'Last 10 Nights campaign + Zakat toggle live' },
|
||||
31: { project: 'pnpl', title: 'Production launch — authentication, multi-tenant, and signup', summary: 'Auth, signup, and landing page live. Dashboard protected. WhatsApp QR setup in settings.' },
|
||||
30: { project: 'pnpl', title: 'Landing page v2 with updated design language' },
|
||||
29: { project: 'pnpl', title: 'Automated reminder system + dashboard rebuild', summary: 'Reminder cron running every 15 minutes. 12 test pledges including instalment plans.' },
|
||||
28: { project: 'pnpl', title: 'Deferred payments and instalment plans', summary: 'Core product shift: a pledge is a promise to pay on a date. Schedule step, instalment plans (2–12 months), date-aware reminders.' },
|
||||
27: { project: 'pnpl', title: 'Premium UI + AI messaging + WhatsApp integration', summary: 'New interface with AI-powered messaging and WhatsApp delivery. Live at pledge.quikcue.com.' },
|
||||
26: { project: 'charityright', title: 'Match funding donation widget for Last 10 Nights' },
|
||||
25: { project: 'pnpl', title: 'Persona-driven features for UK charity market', summary: '6 new pages and APIs targeting 4 UK charity personas. Removed non-UK payment methods.' },
|
||||
24: { project: 'justvitamin', title: 'Board decision summary layout fix' },
|
||||
23: { project: 'pnpl', title: 'Pledge Now Pay Later deployed to production', summary: 'Next.js app with PostgreSQL, 10 database tables, automatic SSL. Seeded with demo organisation.' },
|
||||
22: { project: 'justvitamin', title: 'Dynamic analytics dashboard with API-driven charts', summary: '7 insights that recalculate per date range. Offer charts, hero stats, and ROI calculator — all live data.' },
|
||||
21: { project: 'justvitamin', title: 'Conversion audit — 23 data claims verified', summary: 'Every number on the site checked against raw database. 10 fixes applied. Zero unverified stats.' },
|
||||
20: { project: 'justvitamin', title: 'AI image generation + product page builder', summary: '3 live AI demos powered by Gemini. Image-to-image from real product photos. Renders as real e-commerce pages.' },
|
||||
19: { project: 'justvitamin', title: 'PostgreSQL analytics backend', summary: '17 tables, 20,735 rows from 728K orders. Dashboard loads in 0.2 seconds (previously failed to load).' },
|
||||
17: { project: 'justvitamin', title: 'Password-protected access for the analytics site' },
|
||||
15: { project: 'comms', title: 'WhatsApp gateway upgraded — multi-session support restored', summary: 'Upgraded from free to Plus tier. 2 WhatsApp sessions restored with external HTTPS access.' },
|
||||
14: { project: 'comms', title: 'Live chat system restored after restart loop', summary: 'Stale process file was causing infinite restart. Now serving normally.' },
|
||||
13: { project: 'infra', title: 'Database-backed build log with live API', summary: 'This page. Ships and incidents stored in PostgreSQL. API serves data in real time. Auto-refreshes.' },
|
||||
12: { project: 'infra', title: 'Staging health check — WordPress + Laravel', summary: 'Fixed malware on WordPress. Fixed Laravel (59 pending database migrations, empty tables).' },
|
||||
11: { project: 'justvitamin', title: 'Analytics app deployed to production', summary: 'Python app + PostgreSQL live on Docker Swarm.' },
|
||||
10: { project: 'infra', title: 'Separate Git servers for each organisation', summary: 'Gitea for QuikCue, GitLab for CharityRight. SSH over port 443 through load balancer.' },
|
||||
9: { project: 'quikcue', title: 'Calvana microsite launched', summary: 'Manifesto, live shipping log, and hire page. Running on Docker Swarm.' },
|
||||
8: { project: 'quikcue', title: 'QuikCue operations hub deployed', summary: 'Internal B2B operations platform with background worker.' },
|
||||
7: { project: 'comms', title: 'Full communications stack deployed', summary: 'Live chat (Chatwoot), workflow automation (n8n), and WhatsApp gateway (WAHA) — all self-hosted.' },
|
||||
6: { project: 'charityright', title: 'Laravel admin staging environment', summary: 'Full admin panel with Filament UI, MySQL, and Redis.' },
|
||||
5: { project: 'charityright', title: 'Analytics dashboard deployed' },
|
||||
4: { project: 'charityright', title: 'Checkout flow v2 deployed', summary: 'Next.js checkout with automatic SSL.' },
|
||||
3: { project: 'charityright', title: 'WordPress marketing site (staging)', summary: 'WordPress + MySQL on managed hosting with automatic SSL.' },
|
||||
2: { project: 'charityright', title: 'Data analytics dashboard with Metabase', summary: 'Metabase + PostgreSQL for internal data analytics and reporting.' },
|
||||
1: { project: 'infra', title: 'Full server migration — 14 databases, zero downtime', summary: '2 server containers, load balancer with TLS passthrough, 14 databases migrated, Docker Swarm orchestration. 63GB backup.' },
|
||||
};
|
||||
|
||||
renderStats(stats, ships);
|
||||
renderShips(ships);
|
||||
renderOops(oops);
|
||||
initFilters(ships);
|
||||
}
|
||||
// Oops translations
|
||||
const OOPS_DISPLAY = {
|
||||
19: { title: 'Deployment script accidentally overwrote API server config', fix: 'Restored correct config. 3 min fix.' },
|
||||
18: { title: 'Routing config couldn\'t reach the application container', fix: 'Switched to direct service routing. 15 min fix.' },
|
||||
17: { title: 'Database data lost during deployment path change', fix: 'Copied data from old storage volume to new one. All records restored. 4 min fix.' },
|
||||
16: { title: 'Admin panel crashed after deployment — null reference errors', fix: 'Replaced query methods that broke with null models. 8 min fix.' },
|
||||
15: { title: 'Navigation group name collision in admin panel', fix: 'Renamed conflicting group. Cleared server cache. 5 min fix.' },
|
||||
14: { title: 'Deployment script missed a required variable in admin code', fix: 'Manually corrected 2 files and pushed. 12 min fix.' },
|
||||
13: { title: 'Admin filter crashed before data was ready', fix: 'Removed premature default filter. Restarted server cache. 25 min fix.' },
|
||||
12: { title: 'Two bugs: wrong database syntax + missing code import', fix: 'Fixed MySQL syntax and corrected all 4 broken files. 6 min fix.' },
|
||||
11: { title: 'Dashboard widgets were invisible — registration issue', fix: 'Re-registered the default dashboard page. 5 min fix.' },
|
||||
10: { title: 'AI review failed — rejection messages too long for database column', fix: 'Truncated messages to fit. 3 min fix.' },
|
||||
9: { title: 'Dashboard widget property declaration incompatible with framework', fix: 'Removed the conflicting property. 2 min fix.' },
|
||||
8: { title: 'Deployment script corrupted PHP files containing dollar signs', fix: 'Switched to base64-encoded file transfers. 8 min fix.' },
|
||||
7: { title: 'Product scraper completely broken — pages returned garbled data', fix: 'Removed unsupported compression format from requests. 8 min fix.' },
|
||||
6: { title: 'Analytics claim was unverifiable — 97.4% figure had no data source', fix: 'Corrected to 85% with source data. Fixed across 4 pages. 5 min fix.' },
|
||||
5: { title: 'Email buttons on JustVitamin had no email address', fix: 'Added proper mailto links with subject and body. 3 min fix.' },
|
||||
4: { title: 'WhatsApp gateway only supported 1 session instead of required multiple', fix: 'Upgraded to correct tier and restored sessions. 25 min fix.' },
|
||||
3: { title: 'Log file permissions broken after debugging as wrong user', fix: 'Corrected file ownership and permissions.' },
|
||||
2: { title: 'Application returning errors on every page — 59 pending database updates', fix: 'Ran all migrations and seeded data.' },
|
||||
1: { title: 'WordPress site hacked via malicious plugin', fix: 'Malware deleted, rogue user removed, attack vector blocked.' },
|
||||
};
|
||||
|
||||
// ── Stats ──
|
||||
function renderStats(stats, ships) {
|
||||
const inFlight = ships.filter(s => s.status === 'shipping').length;
|
||||
animateNum('stat-total', stats.total);
|
||||
animateNum('stat-shipped', stats.shipped);
|
||||
animateNum('stat-shipping', inFlight);
|
||||
animateNum('stat-oops', stats.oops);
|
||||
}
|
||||
const PROJECTS = {
|
||||
charityright: { label: 'CharityRight', color: '#3b82f6', desc: 'UK charity donation platform' },
|
||||
pnpl: { label: 'Pledge Now Pay Later', color: '#8b5cf6', desc: 'SaaS pledging product' },
|
||||
justvitamin: { label: 'JustVitamin', color: '#f59e0b', desc: 'E-commerce analytics & AI' },
|
||||
infra: { label: 'Infrastructure', color: '#6b7280', desc: 'Servers, deployment, DevOps' },
|
||||
comms: { label: 'Communications', color: '#10b981', desc: 'WhatsApp, email, automation' },
|
||||
quikcue: { label: 'QuikCue', color: '#00ff9f', desc: 'Company & internal tools' },
|
||||
};
|
||||
|
||||
function animateNum(id, target) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || !target) { if (el) el.textContent = target || '0'; return; }
|
||||
const start = performance.now();
|
||||
const dur = 800;
|
||||
(function step(now) {
|
||||
const p = Math.min((now - start) / dur, 1);
|
||||
const eased = 1 - Math.pow(1 - p, 3);
|
||||
el.textContent = Math.floor(target * eased);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
})(start);
|
||||
}
|
||||
// Auto-detect project from title keywords
|
||||
function detectProject(title) {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes('charityright') || t.includes('charity right') || t.includes('scheduled giving')
|
||||
|| t.includes('regular giving') || t.includes('supporter care') || t.includes('admin panel')
|
||||
|| t.includes('admin v') || t.includes('last 10') || t.includes('gift aid')
|
||||
|| t.includes('persona') || t.includes('landing page') && !t.includes('pnpl'))
|
||||
return 'charityright';
|
||||
if (t.includes('pnpl') || t.includes('pledge') || t.includes('collect') || t.includes('fundrais'))
|
||||
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'))
|
||||
return 'comms';
|
||||
if (t.includes('dokploy') || t.includes('git ') || t.includes('gitea') || t.includes('gitlab')
|
||||
|| t.includes('migration') || t.includes('staging') || t.includes('calvana'))
|
||||
return 'infra';
|
||||
if (t.includes('quikcue') || t.includes('hub'))
|
||||
return 'quikcue';
|
||||
return 'infra';
|
||||
}
|
||||
|
||||
// ── Ships ──
|
||||
function renderShips(ships) {
|
||||
const container = document.getElementById('log-list');
|
||||
// ── Init ──
|
||||
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 }))
|
||||
]);
|
||||
|
||||
// Group by date
|
||||
const groups = {};
|
||||
ships.forEach(s => {
|
||||
const date = (s.created_at || '').split('T')[0] || 'Unknown';
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(s);
|
||||
renderStats(stats, ships);
|
||||
renderFilters(ships);
|
||||
renderShips(ships);
|
||||
renderOops(oops);
|
||||
}
|
||||
|
||||
// ── Stats ──
|
||||
function renderStats(stats, ships) {
|
||||
const inFlight = ships.filter(s => s.status === 'shipping').length;
|
||||
animateNum('stat-total', stats.total);
|
||||
animateNum('stat-shipped', stats.shipped);
|
||||
animateNum('stat-shipping', inFlight);
|
||||
animateNum('stat-oops', stats.oops);
|
||||
}
|
||||
function animateNum(id, target) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (!target) { el.textContent = '0'; return; }
|
||||
const start = performance.now(), dur = 900;
|
||||
(function step(now) {
|
||||
const p = Math.min((now - start) / dur, 1);
|
||||
el.textContent = Math.floor(target * (1 - Math.pow(1 - p, 3)));
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
})(start);
|
||||
}
|
||||
|
||||
// ── Filters ──
|
||||
function renderFilters(ships) {
|
||||
const counts = {};
|
||||
ships.forEach(s => {
|
||||
const d = DISPLAY[s.id];
|
||||
const proj = d ? d.project : detectProject(s.title);
|
||||
counts[proj] = (counts[proj] || 0) + 1;
|
||||
});
|
||||
|
||||
const container = document.getElementById('filters');
|
||||
const order = ['charityright', 'pnpl', 'justvitamin', 'comms', 'infra', 'quikcue'];
|
||||
order.forEach(key => {
|
||||
if (!counts[key]) return;
|
||||
const p = PROJECTS[key];
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'lp-filter';
|
||||
btn.dataset.filter = key;
|
||||
btn.innerHTML = `<span class="lp-filter-dot" style="background:${p.color}"></span>${p.label} <span class="lp-filter-count">${counts[key]}</span>`;
|
||||
container.appendChild(btn);
|
||||
});
|
||||
|
||||
container.querySelectorAll('.lp-filter').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
container.querySelectorAll('.lp-filter').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const f = btn.dataset.filter;
|
||||
document.querySelectorAll('.lp-card').forEach(card => {
|
||||
card.style.display = (f === 'all' || card.dataset.project === f) ? '' : 'none';
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const [date, entries] of Object.entries(groups)) {
|
||||
const formatted = formatDate(date);
|
||||
html += `<div class="log-day">
|
||||
<div class="log-day-header">
|
||||
<span class="log-day-date">${formatted}</span>
|
||||
<span class="log-day-count">${entries.length} ship${entries.length !== 1 ? 's' : ''}</span>
|
||||
</div>`;
|
||||
|
||||
entries.forEach(s => {
|
||||
const badgeClass = s.status === 'shipped' ? 'badge-shipped' :
|
||||
s.status === 'shipping' ? 'badge-shipping' : 'badge-planned';
|
||||
const label = s.status.charAt(0).toUpperCase() + s.status.slice(1);
|
||||
const metric = s.metric && s.metric !== '-' && s.metric !== '—'
|
||||
? `<div class="log-metric">${escapeHtml(s.metric)}</div>` : '';
|
||||
|
||||
html += `<div class="log-entry" data-status="${s.status}">
|
||||
<div class="log-entry-head">
|
||||
<span class="log-entry-id">#${s.id}</span>
|
||||
<span class="live-entry-badge ${badgeClass}">${label}</span>
|
||||
<span class="log-entry-title">${escapeHtml(s.title)}</span>
|
||||
</div>
|
||||
${metric}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Oops ──
|
||||
function renderOops(oops) {
|
||||
const container = document.getElementById('oops-list');
|
||||
if (!oops.length) {
|
||||
container.innerHTML = '<div class="oops-entry">Nothing broken yet. Give it time.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = oops.map(o => {
|
||||
const time = o.fix_time && o.fix_time !== '-' ? ` — fixed in ${escapeHtml(o.fix_time)}` : '';
|
||||
return `<div class="oops-entry">
|
||||
<span class="oops-id">#${o.id}</span>
|
||||
<span>${escapeHtml(o.description)}${time}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Filters ──
|
||||
function initFilters(ships) {
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const filter = btn.dataset.filter;
|
||||
document.querySelectorAll('.log-entry').forEach(entry => {
|
||||
if (filter === 'all' || entry.dataset.status === filter) {
|
||||
entry.style.display = '';
|
||||
} else {
|
||||
entry.style.display = 'none';
|
||||
}
|
||||
});
|
||||
// Hide empty day headers
|
||||
document.querySelectorAll('.log-day').forEach(day => {
|
||||
const visible = day.querySelectorAll('.log-entry:not([style*="display: none"])');
|
||||
day.style.display = visible.length ? '' : 'none';
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.lp-day').forEach(day => {
|
||||
const vis = day.querySelectorAll('.lp-card:not([style*="display: none"])');
|
||||
day.style.display = vis.length ? '' : 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function formatDate(str) {
|
||||
try {
|
||||
const d = new Date(str + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
} catch { return str; }
|
||||
}
|
||||
// ── Ships ──
|
||||
function renderShips(ships) {
|
||||
const container = document.getElementById('log-list');
|
||||
const groups = {};
|
||||
ships.forEach(s => {
|
||||
const date = (s.created_at || '').split('T')[0] || 'Unknown';
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(s);
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
let html = '';
|
||||
for (const [date, entries] of Object.entries(groups)) {
|
||||
html += `<div class="lp-day">
|
||||
<div class="lp-day-header">
|
||||
<span class="lp-day-date">${formatDate(date)}</span>
|
||||
<span class="lp-day-count">${entries.length}</span>
|
||||
</div>`;
|
||||
|
||||
// Live pulse
|
||||
setInterval(() => {
|
||||
const p = document.querySelector('.live-pulse');
|
||||
if (p) { p.classList.remove('ping'); void p.offsetWidth; p.classList.add('ping'); }
|
||||
}, 3000);
|
||||
entries.forEach(s => {
|
||||
const d = DISPLAY[s.id] || {};
|
||||
const proj = d.project || detectProject(s.title);
|
||||
const p = PROJECTS[proj] || PROJECTS.infra;
|
||||
const title = d.title || s.title;
|
||||
const summary = d.summary || '';
|
||||
const isActive = s.status === 'shipping';
|
||||
|
||||
init();
|
||||
</script>
|
||||
html += `<div class="lp-card ${isActive ? 'lp-card-active' : ''}" data-project="${proj}" data-status="${s.status}">
|
||||
<div class="lp-card-row">
|
||||
<span class="lp-card-tag" style="--tag-color:${p.color}">${p.label}</span>
|
||||
<span class="lp-card-title">${esc(title)}</span>
|
||||
${isActive ? '<span class="lp-card-status">Building</span>' : ''}
|
||||
</div>
|
||||
${summary ? `<p class="lp-card-summary">${esc(summary)}</p>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html || '<div class="lp-loading">No entries found.</div>';
|
||||
}
|
||||
|
||||
// ── Oops ──
|
||||
function renderOops(oops) {
|
||||
const container = document.getElementById('oops-list');
|
||||
if (!oops.length) {
|
||||
container.innerHTML = '<p class="lp-oops-empty">Nothing broken yet. Give it time.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = oops.map(o => {
|
||||
const d = OOPS_DISPLAY[o.id] || {};
|
||||
const title = d.title || o.description;
|
||||
const fix = d.fix || (o.fix_time ? `Fixed in ${o.fix_time}` : '');
|
||||
return `<div class="lp-oops-card">
|
||||
<div class="lp-oops-what">${esc(title)}</div>
|
||||
${fix ? `<div class="lp-oops-fix">${esc(fix)}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function formatDate(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(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Live pulse
|
||||
setInterval(() => {
|
||||
const p = document.querySelector('.live-pulse');
|
||||
if (p) { p.classList.remove('ping'); void p.offsetWidth; p.classList.add('ping'); }
|
||||
}, 3000);
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user