6989a98d75
- Arkitektur: docs/auth/passwordless-architecture.md - Backend: iom/quixzoom-auth-service/ (FastAPI + Redis) - Webb: quixzoom-market-pages/se/login/ (QR-kod + polling) - App: iom/quixzoom-app/src/features/auth/ (push + deep links) Flöde: QR-kod → app-godkännande → webb-inloggad
824 lines
44 KiB
HTML
824 lines
44 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="sv" data-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="apple-native-compliance" content="pass">
|
|
<title>Analytics — quiXzoom</title>
|
|
<link rel="stylesheet" >
|
|
<link rel="stylesheet" >
|
|
<script defer></script>
|
|
<style>
|
|
:root {
|
|
--bg: #000000; --bg2: #1C1C1E; --bg3: #2C2C2E;
|
|
--accent: #FF9F0A; --accent2: #FFB340;
|
|
--text: #FFFFFF; --text2: rgba(235,235,245,0.8); --text3: rgba(235,235,245,0.4);
|
|
--card: #1C1C1E; --border: rgba(255,255,255,0.10);
|
|
--green: #30D158; --red: #FF453A; --orange: #FF9F0A;
|
|
--blue: #0A84FF; --purple: #BF5AF2; --teal: #5AC8F5;
|
|
--indigo: #5E5CE6; --yellow: #FFD60A;
|
|
}
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
body{background:var(--bg);color:var(--text);font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}
|
|
.layout{display:flex;height:100vh;overflow:hidden}
|
|
|
|
/* SIDEBAR */
|
|
.sidebar{width:258px;background:rgba(28,28,30,0.97);backdrop-filter:blur(24px);border-right:0.5px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;overflow-y:auto}
|
|
.sidebar-logo{padding:20px 18px 14px;border-bottom:0.5px solid var(--border)}
|
|
.logo-brand{font-size:10px;font-weight:700;letter-spacing:.12em;color:var(--accent);text-transform:uppercase;margin-bottom:3px}
|
|
.logo-name{font-size:17px;font-weight:800;letter-spacing:-.02em}
|
|
.logo-sub{font-size:11px;color:var(--text3);margin-top:2px}
|
|
.sidebar-back{padding:10px 16px;border-bottom:0.5px solid var(--border)}
|
|
.sidebar-back a{color:var(--blue);font-size:13px;text-decoration:none;display:flex;align-items:center;gap:4px}
|
|
.sidebar-section{padding:14px 10px 6px}
|
|
.sidebar-label{font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--text3);text-transform:uppercase;padding:0 8px;margin-bottom:6px}
|
|
.nav-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius: var(--radius-md);cursor:pointer;color:var(--text2);font-size:13px;font-weight:500;transition:all .15s;border:none;background:none;width:100%;text-align:left}
|
|
.nav-item:hover{background:rgba(255,255,255,0.07);color:var(--text)}
|
|
.nav-item.active{background:var(--accent);color:#000}
|
|
.nav-icon{font-size:15px;width:20px;text-align:center}
|
|
.sidebar-footer{margin-top:auto;padding:14px;border-top:0.5px solid var(--border);font-size:11px;color:var(--text3)}
|
|
|
|
/* MAIN */
|
|
.main{flex:1;overflow-y:auto;display:flex;flex-direction:column}
|
|
.topbar{background: rgba(242,242,247,0.85);backdrop-filter:saturate(180%) blur(20px);border-bottom:0.5px solid var(--border);padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
|
|
.topbar-title{font-size:15px;font-weight:700}
|
|
.topbar-actions{display:flex;gap:8px;align-items:center}
|
|
.btn-small{height:30px;padding:0 12px;border-radius: var(--radius-md);border:none;font-size:12px;font-weight:600;cursor:pointer;background:rgba(255,255,255,.1);color:var(--text);transition:all .15s}
|
|
.btn-small:hover{background:rgba(255,255,255,.18)}
|
|
.btn-primary{background:var(--accent);color:#000}
|
|
.btn-primary:hover{background:var(--accent2)}
|
|
.pulse{width:8px;height:8px;border-radius: var(--radius-full);background:var(--green);animation:pulse 2s ease-in-out infinite}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
|
|
.view{display:none;padding:24px}
|
|
.view.active{display:block}
|
|
.page-header{margin-bottom:24px;display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
|
.page-title{font-size:24px;font-weight:800;letter-spacing:-.03em}
|
|
.page-sub{font-size:14px;color:var(--text3);margin-top:4px}
|
|
|
|
/* CARDS */
|
|
.card{background:var(--card);border:0.5px solid var(--border);border-radius: var(--radius-lg);padding:18px}
|
|
.card-title{font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);margin-bottom:14px}
|
|
.metric-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;margin-bottom:24px}
|
|
.metric-card{background:var(--bg2);border:0.5px solid var(--border);border-radius: var(--radius-lg);padding:16px}
|
|
.metric-label{font-size:11px;color:var(--text3);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}
|
|
.metric-value{font-size:22px;font-weight:800;letter-spacing:-.02em}
|
|
.metric-change{font-size:11px;margin-top:4px;font-weight:600}
|
|
.up{color:var(--green)} .down{color:var(--red)} .neutral{color:var(--text3)}
|
|
|
|
.badge{display:inline-flex;align-items:center;font-size:10px;font-weight:700;padding:2px 8px;border-radius: var(--radius-xl);text-transform:uppercase;letter-spacing:.06em}
|
|
.badge-green{background:rgba(48,209,88,.15);color:var(--green)}
|
|
.badge-orange{background:rgba(255,159,10,.15);color:var(--orange)}
|
|
.badge-red{background:rgba(255,69,58,.15);color:var(--red)}
|
|
.badge-blue{background:rgba(10,132,255,.15);color:var(--blue)}
|
|
.badge-indigo{background:rgba(94,92,230,.15);color:var(--indigo)}
|
|
.badge-teal{background:rgba(90,200,245,.15);color:var(--teal)}
|
|
|
|
.row2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px}
|
|
.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:24px}
|
|
@media(max-width:1000px){.row3{grid-template-columns:1fr 1fr}}
|
|
@media(max-width:700px){.row2,.row3{grid-template-columns:1fr}}
|
|
.section-gap{margin-bottom:24px}
|
|
|
|
/* CHARTS */
|
|
.bar-chart{display:flex;flex-direction:column;gap:6px}
|
|
.bar-row{display:flex;align-items:center;gap:10px;font-size:12px}
|
|
.bar-label{width:120px;color:var(--text2);font-weight:500;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.bar-track{flex:1;background:var(--bg3);border-radius: var(--radius-sm);height:10px;overflow:hidden}
|
|
.bar-fill{height:100%;border-radius: var(--radius-sm);transition:width 1s ease}
|
|
.bar-val{width:70px;color:var(--text2);font-size:11px;font-weight:600;text-align:right;flex-shrink:0}
|
|
|
|
/* LINE CHART */
|
|
.line-chart{position:relative;width:100%}
|
|
.line-svg{width:100%;overflow:visible}
|
|
|
|
/* HISTOGRAM */
|
|
.histogram{display:flex;align-items:flex-end;gap:2px;height:100px;padding:0 4px}
|
|
.hist-bar{flex:1;border-radius: var(--radius-sm) 3px 0 0;transition:height .8s ease;min-width:4px;cursor:pointer;position:relative}
|
|
.hist-bar:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--bg3);border:0.5px solid var(--border);padding:3px 6px;border-radius: var(--radius-sm);font-size:10px;white-space:nowrap;color:var(--text);pointer-events:none}
|
|
|
|
/* FUNNEL */
|
|
.funnel-step{margin-bottom:8px}
|
|
.funnel-label-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:12px}
|
|
.funnel-bar-track{background:var(--bg3);border-radius: var(--radius-sm);height:8px;overflow:hidden}
|
|
.funnel-bar-fill{height:100%;border-radius: var(--radius-sm);transition:width 1.2s ease}
|
|
|
|
/* GEO MAP SVG */
|
|
.geo-list{display:flex;flex-direction:column;gap:6px}
|
|
.geo-row{display:flex;align-items:center;gap:10px;padding:8px;background:var(--bg2);border-radius: var(--radius-md)}
|
|
.geo-rank{font-size:13px;font-weight:800;width:22px;text-align:center;color:var(--accent)}
|
|
.geo-name{flex:1;font-size:13px;font-weight:600}
|
|
.geo-bar{width:80px;background:var(--bg3);border-radius: var(--radius-sm);height:6px;overflow:hidden}
|
|
.geo-bar-fill{height:100%;border-radius: var(--radius-sm);background:var(--orange)}
|
|
.geo-missions{font-size:12px;color:var(--text3);width:50px;text-align:right}
|
|
.geo-rate{font-size:12px;font-weight:700;width:40px;text-align:right}
|
|
|
|
/* HEALTH TABLE */
|
|
.data-table{width:100%;border-collapse:collapse;font-size:13px}
|
|
.data-table th{text-align:left;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text3);padding:8px 12px;border-bottom:0.5px solid var(--border)}
|
|
.data-table td{padding:11px 12px;border-bottom:0.5px solid var(--border);color:var(--text2)}
|
|
.data-table tr:last-child td{border-bottom:none}
|
|
.data-table tr:hover td{background:rgba(255,255,255,.03)}
|
|
|
|
/* STATUS DOT */
|
|
.dot{width:8px;height:8px;border-radius: var(--radius-full);display:inline-block;margin-right:4px}
|
|
.dot-ok{background:var(--green)} .dot-warn{background:var(--orange)} .dot-err{background:var(--red)}
|
|
|
|
.loading{color:var(--text3);font-size:13px;padding:40px;text-align:center}
|
|
@media (max-width: 640px) {
|
|
.hero h1 { font-size: 2rem !important; }
|
|
.hero p { font-size: 1rem !important; }
|
|
.card { padding: 1.5rem !important; }
|
|
.grid { grid-template-columns: 1fr !important; }
|
|
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
|
|
}
|
|
</style>
|
|
|
|
<style id="wcag-contrast-enforcement">
|
|
/* AAMOS GLOBAL CONTRAST ENFORCEMENT — WCAG AA (4.5:1 minimum) */
|
|
/* Regel: Ingen text under 70% opacity. Aldrig. */
|
|
body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
|
|
/* Fix common low-contrast patterns */
|
|
[style*="rgba(60,60,67,0.4)"] { color: rgba(60,60,67,0.65) !important; }
|
|
[style*="rgba(60,60,67,0.3)"] { color: rgba(60,60,67,0.65) !important; }
|
|
[style*="rgba(255,255,255,0.4)"] { color: rgba(255,255,255,0.72) !important; }
|
|
[style*="rgba(255,255,255,0.3)"] { color: rgba(255,255,255,0.72) !important; }
|
|
[style*="opacity: 0.4"] { opacity: 0.72 !important; }
|
|
[style*="opacity: 0.3"] { opacity: 0.72 !important; }
|
|
@media (max-width: 640px) {
|
|
.hero h1 { font-size: 2rem !important; }
|
|
.hero p { font-size: 1rem !important; }
|
|
.card { padding: 1.5rem !important; }
|
|
.grid { grid-template-columns: 1fr !important; }
|
|
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="layout">
|
|
|
|
<!-- SIDEBAR -->
|
|
<nav class="sidebar">
|
|
<div class="sidebar-logo">
|
|
<div class="logo-brand">quiXzoom</div>
|
|
<div class="logo-name">Analytics</div>
|
|
<div class="logo-sub">Mission & Zoomer Intelligence</div>
|
|
</div>
|
|
<div class="sidebar-back">
|
|
<a href="admin.html">← quiXzoom Admin</a>
|
|
</div>
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-label">Dashboards</div>
|
|
<button class="nav-item active" onclick="showView('overview',this)"><span class="nav-icon"></span> Overview</button>
|
|
<button class="nav-item" onclick="showView('success',this)"><span class="nav-icon"><svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#22c55e" stroke-width="2"/><path d="M6 10L9 13L14 7" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span> Mission Success</button>
|
|
<button class="nav-item" onclick="showView('earnings',this)"><span class="nav-icon"></span> Zoomer Earnings</button>
|
|
<button class="nav-item" onclick="showView('funnel',this)"><span class="nav-icon"></span> KYC Funnel</button>
|
|
<button class="nav-item" onclick="showView('geo',this)"><span class="nav-icon"></span> Geographic</button>
|
|
<button class="nav-item" onclick="showView('revenue',this)"><span class="nav-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 12L6 8L9 11L14 5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 5H14V9" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span> Revenue</button>
|
|
<button class="nav-item" onclick="showView('health',this)"><span class="nav-icon"></span> Platform Health</button>
|
|
</div>
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-label">Relaterat</div>
|
|
<button class="nav-item" onclick="window.location='/ouroboros/digital-twin.html'"><span class="nav-icon"></span> Digital Twin</button>
|
|
<button class="nav-item" onclick="window.location='/quixzoom/dac7-reporting.html'"><span class="nav-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></span> DAC7 Report</button>
|
|
<button class="nav-item" onclick="window.location='/ouroboros/analytics-advanced.html'"><span class="nav-icon"></span> Predictive AI</button>
|
|
</div>
|
|
<div class="sidebar-footer">
|
|
Uppdateras var 30s<br>
|
|
Senast: <span id="last-updated">—</span>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- MAIN -->
|
|
<div class="main">
|
|
<div class="topbar">
|
|
<div class="topbar-title"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M9 2L5 9H8L7 14L11 7H8L9 2Z" fill="#f59e0b"/></svg> quiXzoom Analytics</div>
|
|
<div class="topbar-actions">
|
|
<div class="pulse" style="margin-right:4px"></div>
|
|
<span style="font-size:12px;color:var(--text3)">Live</span>
|
|
<button class="btn-small" onclick="loadAll()">↻ Refresh</button>
|
|
<button class="btn-small btn-primary">↓ Export CSV</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VIEW: OVERVIEW -->
|
|
<div class="view active" id="view-overview">
|
|
<div class="page-header">
|
|
<div>
|
|
<div class="page-title">Platform Overview</div>
|
|
<div class="page-sub">KPIs i realtid — senaste 30 dagarna</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid" id="overview-metrics">
|
|
<div class="loading">Laddar...</div>
|
|
</div>
|
|
<div class="row2 section-gap">
|
|
<div class="card" id="trend-card">
|
|
<div class="card-title">Mission Success Rate — 90 dagar</div>
|
|
<div id="trend-chart" style="height:140px;position:relative"></div>
|
|
</div>
|
|
<div class="card" id="rev-type-card">
|
|
<div class="card-title">Revenue per uppdragstyp</div>
|
|
<div class="bar-chart" id="rev-mini-chart"></div>
|
|
</div>
|
|
</div>
|
|
<div class="card section-gap" id="services-table-overview">
|
|
<div class="card-title">Live Systemstatus</div>
|
|
<div id="services-mini" class="loading">Laddar...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VIEW: MISSION SUCCESS -->
|
|
<div class="view" id="view-success">
|
|
<div class="page-header">
|
|
<div>
|
|
<div class="page-title">Mission Success Rate</div>
|
|
<div class="page-sub">90-dagars trend — daglig framgångsgrad</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid" id="success-metrics"></div>
|
|
<div class="card section-gap">
|
|
<div class="card-title">90-dagars trend (daglig success rate)</div>
|
|
<div id="success-trend-chart" style="height:180px;position:relative;margin-top:8px"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Senaste 14 dagars data</div>
|
|
<table class="data-table" id="success-table">
|
|
<thead><tr><th>Datum</th><th>Totalt</th><th>Slutförda</th><th>Success Rate</th><th>Trend</th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VIEW: EARNINGS -->
|
|
<div class="view" id="view-earnings">
|
|
<div class="page-header">
|
|
<div>
|
|
<div class="page-title">Zoomer Earnings</div>
|
|
<div class="page-sub">Inkomstfördelning bland aktiva Zoomers</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid" id="earnings-metrics"></div>
|
|
<div class="card section-gap">
|
|
<div class="card-title">Inkomsthistogram (SEK per uppdrag)</div>
|
|
<div class="histogram" id="earnings-histogram" style="height:120px"></div>
|
|
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--text3);margin-top:6px;padding:0 4px">
|
|
<span>0 SEK</span><span>1 250</span><span>2 500</span><span>3 750</span><span>5 000 SEK</span>
|
|
</div>
|
|
</div>
|
|
<div class="row2">
|
|
<div class="card">
|
|
<div class="card-title">Percentiler</div>
|
|
<div id="percentiles"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Top Earning Kategorier</div>
|
|
<div class="bar-chart" id="earnings-cat-chart" style="margin-top:4px"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VIEW: KYC FUNNEL -->
|
|
<div class="view" id="view-funnel">
|
|
<div class="page-header">
|
|
<div>
|
|
<div class="page-title">KYC Conversion Funnel</div>
|
|
<div class="page-sub">Registrering → Aktiv Zoomer</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid" id="funnel-metrics"></div>
|
|
<div class="row2">
|
|
<div class="card">
|
|
<div class="card-title">KYC Funnel</div>
|
|
<div id="kyc-funnel" style="margin-top:8px"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Mission Funnel</div>
|
|
<div id="mission-funnel" style="margin-top:8px"></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Drop-off Insights</div>
|
|
<div id="dropoff-insights"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VIEW: GEOGRAPHIC -->
|
|
<div class="view" id="view-geo">
|
|
<div class="page-header">
|
|
<div>
|
|
<div class="page-title">Geographic Heatmap</div>
|
|
<div class="page-sub">Uppdrag och Zoomers per region i Sverige</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid" id="geo-metrics"></div>
|
|
<div class="row2">
|
|
<div class="card">
|
|
<div class="card-title">Regioner — Uppdragsvolym</div>
|
|
<div class="geo-list" id="geo-list"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Success Rate per Region</div>
|
|
<div class="bar-chart" id="geo-success-chart" style="margin-top:4px"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VIEW: REVENUE -->
|
|
<div class="view" id="view-revenue">
|
|
<div class="page-header">
|
|
<div>
|
|
<div class="page-title">Revenue per Mission Type</div>
|
|
<div class="page-sub">90-dagars intäktsanalys per uppdragskategori</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid" id="revenue-metrics"></div>
|
|
<div class="card section-gap">
|
|
<div class="card-title">Intäkter per kategori</div>
|
|
<div class="bar-chart" id="revenue-chart"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Detaljerad tabell</div>
|
|
<table class="data-table" id="revenue-table">
|
|
<thead><tr><th>Uppdragstyp</th><th>Uppdrag</th><th>Intäkt (SEK)</th><th>Snitt/uppdrag</th><th>Andel</th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VIEW: PLATFORM HEALTH -->
|
|
<div class="view" id="view-health">
|
|
<div class="page-header">
|
|
<div>
|
|
<div class="page-title">Platform Health</div>
|
|
<div class="page-sub">API-latency, uptime och live-kapacitet</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid" id="health-metrics"></div>
|
|
<div class="row2">
|
|
<div class="card">
|
|
<div class="card-title">API Latency Percentiler</div>
|
|
<div id="latency-bars"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Realtidskapacitet</div>
|
|
<div id="realtime-stats"></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Tjänstestatus</div>
|
|
<table class="data-table" id="services-table">
|
|
<thead><tr><th>Tjänst</th><th>Status</th><th>Latency</th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /main -->
|
|
</div><!-- /layout -->
|
|
|
|
<script>
|
|
/* Utilities */
|
|
function fmt(n){return n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'K':String(n)}
|
|
function fmtSEK(n){return new Intl.NumberFormat('sv-SE').format(Math.round(n))}
|
|
function pct(v,d=1){return (v*100).toFixed(d)+'%'}
|
|
function rnd(a,b){return Math.floor(Math.random()*(b-a+1))+a}
|
|
|
|
function showView(name, btn) {
|
|
document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));
|
|
document.querySelectorAll('.nav-item').forEach(b=>b.classList.remove('active'));
|
|
document.getElementById('view-'+name).classList.add('active');
|
|
if(btn) btn.classList.add('active');
|
|
}
|
|
|
|
async function apiFetch(path) {
|
|
try {
|
|
const r = await fetch(path);
|
|
if (r.ok) return r.json();
|
|
} catch(e) { /* fall through to seed */ }
|
|
return null;
|
|
}
|
|
|
|
/* Inline seed generators (fallback when API unavailable) */
|
|
function seedOverview() {
|
|
return {
|
|
missions_total:18742+rnd(-50,50), missions_this_month:1284+rnd(-20,20),
|
|
success_rate:+(0.837+Math.random()*0.034).toFixed(3),
|
|
avg_earnings:1420+rnd(0,260), avg_earnings_currency:'SEK',
|
|
active_zoomers:rnd(312,389), registered_zoomers:2841+rnd(-10,10),
|
|
kyc_approved:2219+rnd(-5,5), missions_today:rnd(34,78),
|
|
revenue_mtd:184000+rnd(0,28000), revenue_currency:'SEK',
|
|
avg_completion_min:+(22+Math.random()*16).toFixed(1),
|
|
nps_score:rnd(54,71), updated_at:new Date().toISOString()
|
|
};
|
|
}
|
|
function seedTrend() {
|
|
const trend=[]; const now=Date.now();
|
|
for(let i=89;i>=0;i--) {
|
|
const d=new Date(now-i*86400000);
|
|
const total=rnd(18,95); const success=rnd(Math.floor(total*.65),Math.floor(total*.94));
|
|
trend.push({date:d.toISOString().slice(0,10),total,success,rate:+(success/total).toFixed(3)});
|
|
}
|
|
return {trend,updated_at:new Date().toISOString()};
|
|
}
|
|
function seedFunnel() {
|
|
return {
|
|
kyc_funnel:[
|
|
{step:'App registrerad',count:4218,pct:1},{step:'E-post verifierad',count:3891,pct:.923},
|
|
{step:'BankID initierad',count:3102,pct:.735},{step:'BankID godkänd',count:2841,pct:.673},
|
|
{step:'ID-dokument uploadat',count:2612,pct:.619},{step:'KYC-granskning klar',count:2319,pct:.550},
|
|
{step:'Zoomer aktiv',count:2219,pct:.526}
|
|
],
|
|
mission_funnel:[
|
|
{step:'Uppdrag publicerat',count:22841,pct:1},{step:'Minst 1 anbud',count:21204,pct:.928},
|
|
{step:'Uppdrag tilldelat',count:19987,pct:.875},{step:'Zoomer på väg',count:19401,pct:.849},
|
|
{step:'Uppdrag startade',count:19188,pct:.840},{step:'Uppdrag slutfört',count:18742,pct:.820},
|
|
{step:'Betalning utbetald',count:18519,pct:.811}
|
|
],
|
|
drop_off_insights:[
|
|
{funnel:'KYC',step:'BankID initierad → godkänd',drop_pct:.262,reason:'Timeout / fel BankID-app'},
|
|
{funnel:'Mission',step:'Tilldelat → startade',drop_pct:.040,reason:'Zoomer no-show / avbokning'}
|
|
]
|
|
};
|
|
}
|
|
function seedGeo() {
|
|
return {regions:[
|
|
{region:'Stockholm',missions:6000+rnd(-200,200),success_rate:.88+Math.random()*.03,active_zoomers:rnd(98,128)},
|
|
{region:'Göteborg',missions:3150+rnd(-200,200),success_rate:.86+Math.random()*.03,active_zoomers:rnd(62,88)},
|
|
{region:'Malmö',missions:2000+rnd(-200,200),success_rate:.85+Math.random()*.03,active_zoomers:rnd(38,52)},
|
|
{region:'Uppsala',missions:960+rnd(-80,80),success_rate:.87+Math.random()*.03,active_zoomers:rnd(18,28)},
|
|
{region:'Västerås',missions:640+rnd(-60,60),success_rate:.84+Math.random()*.03,active_zoomers:rnd(12,19)},
|
|
{region:'Örebro',missions:520+rnd(-60,60),success_rate:.83+Math.random()*.03,active_zoomers:rnd(9,15)},
|
|
{region:'Linköping',missions:580+rnd(-60,60),success_rate:.85+Math.random()*.03,active_zoomers:rnd(10,16)},
|
|
{region:'Helsingborg',missions:460+rnd(-60,60),success_rate:.84+Math.random()*.03,active_zoomers:rnd(8,14)},
|
|
{region:'Jönköping',missions:400+rnd(-60,60),success_rate:.83+Math.random()*.03,active_zoomers:rnd(7,12)},
|
|
{region:'Umeå',missions:250+rnd(-40,40),success_rate:.81+Math.random()*.03,active_zoomers:rnd(5,10)},
|
|
{region:'Övriga',missions:1150+rnd(-100,100),success_rate:.82+Math.random()*.03,active_zoomers:rnd(22,35)}
|
|
]};
|
|
}
|
|
function seedRevenue() {
|
|
return {mission_types:[
|
|
{type:'Foto & Video',missions:5000+rnd(-200,200),revenue_sek:2400000+rnd(-200000,200000),avg_per_mission:480+rnd(-40,40),share:.31},
|
|
{type:'Leverans & Logistik',missions:4500+rnd(-200,200),revenue_sek:1600000+rnd(-200000,200000),avg_per_mission:360+rnd(-40,40),share:.22},
|
|
{type:'Event & Konferens',missions:3000+rnd(-200,200),revenue_sek:2000000+rnd(-200000,200000),avg_per_mission:650+rnd(-40,40),share:.18},
|
|
{type:'Undersökning',missions:2400+rnd(-200,200),revenue_sek:990000+rnd(-100000,100000),avg_per_mission:420+rnd(-40,40),share:.14},
|
|
{type:'Social Media',missions:2000+rnd(-200,200),revenue_sek:720000+rnd(-100000,100000),avg_per_mission:360+rnd(-40,40),share:.11},
|
|
{type:'Övrigt',missions:750+rnd(-100,100),revenue_sek:310000+rnd(-50000,50000),avg_per_mission:400+rnd(-40,40),share:.04}
|
|
],total_revenue_sek:8000000+rnd(-800000,800000),currency:'SEK',period:'90d'};
|
|
}
|
|
function seedEarnings() {
|
|
return {histogram:[{range_start:0,range_end:249,mid:125,count:4},{range_start:250,range_end:499,mid:375,count:12},{range_start:500,range_end:749,mid:625,count:28},{range_start:750,range_end:999,mid:875,count:52},{range_start:1000,range_end:1249,mid:1125,count:83},{range_start:1250,range_end:1499,mid:1375,count:112},{range_start:1500,range_end:1749,mid:1625,count:145},{range_start:1750,range_end:1999,mid:1875,count:132},{range_start:2000,range_end:2249,mid:2125,count:108},{range_start:2250,range_end:2499,mid:2375,count:87},{range_start:2500,range_end:2749,mid:2625,count:64},{range_start:2750,range_end:2999,mid:2875,count:48},{range_start:3000,range_end:3249,mid:3125,count:36},{range_start:3250,range_end:3499,mid:3375,count:24},{range_start:3500,range_end:3749,mid:3625,count:18},{range_start:3750,range_end:3999,mid:3875,count:12},{range_start:4000,range_end:4249,mid:4125,count:8},{range_start:4250,range_end:4499,mid:4375,count:5},{range_start:4500,range_end:4749,mid:4625,count:3},{range_start:4750,range_end:4999,mid:4875,count:2}],currency:'SEK',p10:420,p25:820,median:1380,p75:2140,p90:3120,mean:1547,std_dev:890};
|
|
}
|
|
function seedHealth() {
|
|
return {api_latency_ms:{p50:rnd(42,68),p95:rnd(180,320),p99:rnd(480,820)},error_rate:+(Math.random()*0.006+0.002).toFixed(4),uptime_pct_30d:+(99.82+Math.random()*0.16).toFixed(2),active_zoomers_now:rnd(38,94),active_missions_now:rnd(12,45),websocket_conns:rnd(180,420),queue_depth:rnd(0,12),db_latency_ms:rnd(4,18),cache_hit_rate:+(0.88+Math.random()*0.08).toFixed(2),services:[{name:'API Gateway',status:'ok',latency_ms:rnd(8,18)},{name:'Mission Engine',status:'ok',latency_ms:rnd(12,28)},{name:'Matching AI',status:'ok',latency_ms:rnd(80,180)},{name:'KYC Service',status:'ok',latency_ms:rnd(20,45)},{name:'Payout Engine',status:'ok',latency_ms:rnd(15,35)},{name:'Push Notifications',status:'ok',latency_ms:rnd(5,12)},{name:'Geolocation',status:'ok',latency_ms:rnd(18,42)}]};
|
|
}
|
|
async function fetchOrSeed(path, seedFn) {
|
|
const d = await apiFetch(path);
|
|
return d || seedFn();
|
|
}
|
|
|
|
/* OVERVIEW */
|
|
async function loadOverview() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/overview', seedOverview);
|
|
if(!d) return;
|
|
|
|
document.getElementById('overview-metrics').innerHTML = [
|
|
{label:'Uppdrag totalt', value:fmtSEK(d.missions_total), change:'+12.4%', up:true, color:'var(--orange)'},
|
|
{label:'Denna månad', value:fmtSEK(d.missions_this_month),change:'+8.1%',up:true, color:'var(--blue)'},
|
|
{label:'Success Rate', value:pct(d.success_rate,1), change:'+1.2pp',up:true, color:'var(--green)'},
|
|
{label:'Avg inkomst', value:fmtSEK(d.avg_earnings)+' kr',change:'+3.8%', up:true, color:'var(--yellow)'},
|
|
{label:'Aktiva Zoomers', value:fmtSEK(d.active_zoomers), change:'+5', up:true, color:'var(--teal)'},
|
|
{label:'Idag', value:fmtSEK(d.missions_today), change:'Live', up:null, color:'var(--purple)'},
|
|
{label:'Revenue MTD', value:fmtSEK(d.revenue_mtd)+' kr',change:'+9.3%', up:true, color:'var(--green)'},
|
|
{label:'NPS Score', value:d.nps_score, change:'+4', up:true, color:'var(--orange)'},
|
|
].map(m=>`
|
|
<div class="metric-card">
|
|
<div class="metric-label">${m.label}</div>
|
|
<div class="metric-value" style="color:${m.color}">${m.value}</div>
|
|
<div class="metric-change ${m.up===true?'up':m.up===false?'down':'neutral'}">${m.up===true?'↑':m.up===false?'↓':''}${m.change}</div>
|
|
</div>`).join('');
|
|
|
|
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString('sv-SE');
|
|
}
|
|
|
|
async function loadRevMiniChart() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/revenue', seedRevenue);
|
|
if(!d) return;
|
|
const max = Math.max(...d.mission_types.map(t=>t.revenue_sek));
|
|
document.getElementById('rev-mini-chart').innerHTML = d.mission_types.map(t=>`
|
|
<div class="bar-row">
|
|
<span class="bar-label">${t.type}</span>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${(t.revenue_sek/max*100).toFixed(1)}%;background:var(--orange)"></div></div>
|
|
<span class="bar-val">${fmtSEK(t.revenue_sek)} kr</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
async function loadServicesMini() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/health', seedHealth);
|
|
if(!d) return;
|
|
document.getElementById('services-mini').innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:8px">
|
|
${d.services.map(s=>`<span style="background:var(--bg2);border:0.5px solid var(--border);border-radius: var(--radius-md);padding:6px 12px;font-size:12px;font-weight:600">
|
|
<span class="dot dot-ok"></span>${s.name} <span style="color:var(--text3)">${s.latency_ms}ms</span>
|
|
</span>`).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
/* SUCCESS TREND */
|
|
async function loadSuccess() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/trend', seedTrend);
|
|
if(!d) return;
|
|
const trend = d.trend;
|
|
|
|
const rates = trend.map(t=>t.rate*100);
|
|
const minR = Math.min(...rates)-1, maxR = Math.max(...rates)+1;
|
|
const range = maxR-minR;
|
|
const W=100, H=180;
|
|
|
|
// SVG line chart
|
|
function toX(i){ return (i/(trend.length-1)*100).toFixed(2) }
|
|
function toY(v){ return ((1-(v-minR)/range)*H).toFixed(2) }
|
|
|
|
const pts = trend.map((t,i)=>`${toX(i)},${toY(t.rate*100)}`).join(' ');
|
|
const area = `M${toX(0)},${H} `+trend.map((t,i)=>`L${toX(i)},${toY(t.rate*100)}`).join(' ')+` L${toX(trend.length-1)},${H} Z`;
|
|
|
|
document.getElementById('trend-chart').innerHTML = `
|
|
<svg viewBox="0 0 100 ${H}" preserveAspectRatio="none" style="width:100%;height:${H}px">
|
|
<defs>
|
|
<linearGradient id="tg" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="var(--orange)" stop-opacity=".35"/>
|
|
<stop offset="100%" stop-color="var(--orange)" stop-opacity="0"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<path d="${area}" fill="url(#tg)"/>
|
|
<polyline points="${pts}" fill="none" stroke="var(--orange)" stroke-width=".8" stroke-linejoin="round"/>
|
|
</svg>
|
|
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--text3);margin-top:4px"><span>90 dagar sedan</span><span>Idag</span></div>`;
|
|
|
|
// Success view metrics
|
|
const last7 = trend.slice(-7);
|
|
const avgRate = last7.reduce((a,t)=>a+t.rate,0)/7;
|
|
document.getElementById('success-metrics').innerHTML = [
|
|
{label:'Snitt success rate', value:pct(avgRate,1), sub:'senaste 7d', color:'var(--green)'},
|
|
{label:'Idag', value:pct(trend[trend.length-1].rate,1), sub:'success rate', color:'var(--orange)'},
|
|
{label:'Totalt 90d', value:fmtSEK(trend.reduce((a,t)=>a+t.total,0)), sub:'uppdrag', color:'var(--blue)'},
|
|
{label:'Slutförda 90d', value:fmtSEK(trend.reduce((a,t)=>a+t.success,0)), sub:'uppdrag', color:'var(--teal)'},
|
|
].map(m=>`<div class="metric-card"><div class="metric-label">${m.label}</div><div class="metric-value" style="color:${m.color}">${m.value}</div><div class="metric-sub neutral">${m.sub}</div></div>`).join('');
|
|
|
|
// Success chart (same svg but taller)
|
|
document.getElementById('success-trend-chart').innerHTML = `
|
|
<svg viewBox="0 0 100 ${H}" preserveAspectRatio="none" style="width:100%;height:${H}px">
|
|
<defs><linearGradient id="tg2" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="var(--green)" stop-opacity=".3"/>
|
|
<stop offset="100%" stop-color="var(--green)" stop-opacity="0"/>
|
|
</linearGradient></defs>
|
|
<path d="${area.replace('var(--orange)','var(--green)')}" fill="url(#tg2)"/>
|
|
<polyline points="${pts}" fill="none" stroke="var(--green)" stroke-width=".8" stroke-linejoin="round"/>
|
|
</svg>
|
|
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--text3);margin-top:4px"><span>${trend[0].date}</span><span>Idag</span></div>`;
|
|
|
|
// Table last 14 days
|
|
const last14 = trend.slice(-14).reverse();
|
|
document.querySelector('#success-table tbody').innerHTML = last14.map((t,i)=>{
|
|
const prev = trend[trend.length-1-i-1];
|
|
const delta = prev ? t.rate-prev.rate : 0;
|
|
return `<tr>
|
|
<td style="font-weight:600">${t.date}</td>
|
|
<td>${t.total}</td>
|
|
<td>${t.success}</td>
|
|
<td><span style="color:${t.rate>0.87?'var(--green)':t.rate>0.80?'var(--orange)':'var(--red)'};font-weight:700">${pct(t.rate,1)}</span></td>
|
|
<td style="color:${delta>=0?'var(--green)':'var(--red)'};font-weight:600">${delta>=0?'↑':'↓'}${Math.abs(delta*100).toFixed(2)}pp</td>
|
|
</tr>`;}).join('');
|
|
}
|
|
|
|
/* EARNINGS */
|
|
async function loadEarnings() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/earnings', seedEarnings);
|
|
if(!d) return;
|
|
|
|
document.getElementById('earnings-metrics').innerHTML = [
|
|
{label:'Median', value:fmtSEK(d.median)+' kr', color:'var(--orange)'},
|
|
{label:'Snitt', value:fmtSEK(d.mean)+' kr', color:'var(--blue)'},
|
|
{label:'P75', value:fmtSEK(d.p75)+' kr', color:'var(--green)'},
|
|
{label:'P90', value:fmtSEK(d.p90)+' kr', color:'var(--teal)'},
|
|
{label:'P10', value:fmtSEK(d.p10)+' kr', color:'var(--text3)'},
|
|
{label:'Std dev', value:fmtSEK(d.std_dev)+' kr', color:'var(--text3)'},
|
|
].map(m=>`<div class="metric-card"><div class="metric-label">${m.label}</div><div class="metric-value" style="color:${m.color}">${m.value}</div></div>`).join('');
|
|
|
|
// Histogram
|
|
const hist = d.histogram;
|
|
const maxC = Math.max(...hist.map(b=>b.count));
|
|
document.getElementById('earnings-histogram').innerHTML = hist.map(b=>`
|
|
<div class="hist-bar"
|
|
style="height:${(b.count/maxC*100).toFixed(1)}%;background:linear-gradient(180deg,var(--orange),rgba(255,159,10,.4))"
|
|
data-label="${b.range_start}-${b.range_end} SEK: ${b.count} zoomers">
|
|
</div>`).join('');
|
|
|
|
// Percentiles visual
|
|
const percs = [
|
|
{label:'P10',value:d.p10}, {label:'P25',value:d.p25},
|
|
{label:'P50',value:d.median},{label:'P75',value:d.p75},
|
|
{label:'P90',value:d.p90},
|
|
];
|
|
document.getElementById('percentiles').innerHTML = percs.map(p=>`
|
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:0.5px solid var(--border)">
|
|
<span style="font-size:13px;font-weight:600;color:var(--text3)">${p.label}</span>
|
|
<span style="font-size:17px;font-weight:800;color:var(--orange)">${fmtSEK(p.value)} SEK</span>
|
|
</div>`).join('');
|
|
|
|
// Category chart
|
|
const cats = [
|
|
{label:'Event & Konferens', avg:650},{label:'Foto & Video', avg:480},
|
|
{label:'Undersökning', avg:420},{label:'Social Media', avg:360},
|
|
{label:'Leverans', avg:360},{label:'Övrigt', avg:400},
|
|
];
|
|
const maxA = Math.max(...cats.map(c=>c.avg));
|
|
document.getElementById('earnings-cat-chart').innerHTML = cats.map(c=>`
|
|
<div class="bar-row">
|
|
<span class="bar-label">${c.label}</span>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${(c.avg/maxA*100).toFixed(1)}%;background:var(--teal)"></div></div>
|
|
<span class="bar-val">${c.avg} SEK</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
/* KYC FUNNEL */
|
|
async function loadFunnel() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/funnel', seedFunnel);
|
|
if(!d) return;
|
|
|
|
const kf = d.kyc_funnel, mf = d.mission_funnel;
|
|
const colors = ['var(--blue)','var(--teal)','var(--green)','var(--orange)','var(--yellow)','var(--purple)','var(--accent)'];
|
|
|
|
document.getElementById('kyc-funnel').innerHTML = kf.map((s,i)=>`
|
|
<div class="funnel-step">
|
|
<div class="funnel-label-row">
|
|
<span style="font-size:12px;font-weight:500">${s.step}</span>
|
|
<span style="font-size:12px;font-weight:700;color:${colors[i%colors.length]}">${fmtSEK(s.count)} <span style="color:var(--text3)">${pct(s.pct)}</span></span>
|
|
</div>
|
|
<div class="funnel-bar-track">
|
|
<div class="funnel-bar-fill" style="width:${(s.pct*100).toFixed(1)}%;background:${colors[i%colors.length]}"></div>
|
|
</div>
|
|
</div>`).join('');
|
|
|
|
document.getElementById('mission-funnel').innerHTML = mf.map((s,i)=>`
|
|
<div class="funnel-step">
|
|
<div class="funnel-label-row">
|
|
<span style="font-size:12px;font-weight:500">${s.step}</span>
|
|
<span style="font-size:12px;font-weight:700;color:${colors[i%colors.length]}">${fmtSEK(s.count)} <span style="color:var(--text3)">${pct(s.pct)}</span></span>
|
|
</div>
|
|
<div class="funnel-bar-track">
|
|
<div class="funnel-bar-fill" style="width:${(s.pct*100).toFixed(1)}%;background:${colors[i%colors.length]}"></div>
|
|
</div>
|
|
</div>`).join('');
|
|
|
|
document.getElementById('funnel-metrics').innerHTML = [
|
|
{label:'KYC Conversion', value:pct(kf[kf.length-1].pct,1), color:'var(--green)'},
|
|
{label:'Mission Success', value:pct(mf[mf.length-1].pct,1), color:'var(--blue)'},
|
|
{label:'Aktiva Zoomers', value:fmtSEK(kf[kf.length-1].count), color:'var(--orange)'},
|
|
{label:'KYC Dropout', value:pct(1-kf[kf.length-1].pct,1), color:'var(--red)'},
|
|
].map(m=>`<div class="metric-card"><div class="metric-label">${m.label}</div><div class="metric-value" style="color:${m.color}">${m.value}</div></div>`).join('');
|
|
|
|
document.getElementById('dropoff-insights').innerHTML = d.drop_off_insights.map(i=>`
|
|
<div style="display:flex;align-items:center;gap:14px;padding:14px;background:var(--bg2);border-radius: var(--radius-md);margin-bottom:8px">
|
|
<span class="badge badge-orange">${i.funnel}</span>
|
|
<div style="flex:1">
|
|
<div style="font-size:13px;font-weight:600">${i.step}</div>
|
|
<div style="font-size:12px;color:var(--text3);margin-top:2px">${i.reason}</div>
|
|
</div>
|
|
<span style="font-size:16px;font-weight:800;color:var(--red)">-${pct(i.drop_pct,1)}</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
/* GEOGRAPHIC */
|
|
async function loadGeo() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/geographic', seedGeo);
|
|
if(!d) return;
|
|
const regs = d.regions;
|
|
const maxM = Math.max(...regs.map(r=>r.missions));
|
|
|
|
document.getElementById('geo-metrics').innerHTML = [
|
|
{label:'Regioner', value:regs.length, color:'var(--blue)'},
|
|
{label:'Topregion', value:regs[0].region, color:'var(--orange)'},
|
|
{label:'Total uppdrag', value:fmtSEK(regs.reduce((a,r)=>a+r.missions,0)), color:'var(--green)'},
|
|
{label:'Aktiva Zoomers', value:fmtSEK(regs.reduce((a,r)=>a+r.active_zoomers,0)), color:'var(--teal)'},
|
|
].map(m=>`<div class="metric-card"><div class="metric-label">${m.label}</div><div class="metric-value" style="color:${m.color}">${m.value}</div></div>`).join('');
|
|
|
|
document.getElementById('geo-list').innerHTML = regs.slice(0,10).map((r,i)=>`
|
|
<div class="geo-row">
|
|
<span class="geo-rank">${i+1}</span>
|
|
<span class="geo-name">${r.region}</span>
|
|
<div class="geo-bar"><div class="geo-bar-fill" style="width:${(r.missions/maxM*100).toFixed(1)}%"></div></div>
|
|
<span class="geo-missions">${fmtSEK(r.missions)}</span>
|
|
<span class="geo-rate" style="color:${r.success_rate>0.86?'var(--green)':'var(--orange)'}">${pct(r.success_rate,0)}</span>
|
|
</div>`).join('');
|
|
|
|
document.getElementById('geo-success-chart').innerHTML = regs.slice(0,8).map(r=>`
|
|
<div class="bar-row">
|
|
<span class="bar-label">${r.region}</span>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${(r.success_rate*100).toFixed(1)}%;background:${r.success_rate>0.86?'var(--green)':'var(--orange)'}"></div></div>
|
|
<span class="bar-val">${pct(r.success_rate,1)}</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
/* REVENUE */
|
|
async function loadRevenue() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/revenue', seedRevenue);
|
|
if(!d) return;
|
|
const types = d.mission_types;
|
|
const maxR = Math.max(...types.map(t=>t.revenue_sek));
|
|
|
|
document.getElementById('revenue-metrics').innerHTML = [
|
|
{label:'Total Revenue', value:fmtSEK(d.total_revenue_sek)+' kr', color:'var(--green)'},
|
|
{label:'Valuta', value:d.currency, color:'var(--blue)'},
|
|
{label:'Period', value:d.period, color:'var(--text3)'},
|
|
{label:'Kategorier', value:types.length, color:'var(--orange)'},
|
|
].map(m=>`<div class="metric-card"><div class="metric-label">${m.label}</div><div class="metric-value" style="color:${m.color}">${m.value}</div></div>`).join('');
|
|
|
|
const cols=['var(--orange)','var(--blue)','var(--green)','var(--purple)','var(--teal)','var(--yellow)'];
|
|
document.getElementById('revenue-chart').innerHTML = types.map((t,i)=>`
|
|
<div class="bar-row">
|
|
<span class="bar-label">${t.type}</span>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${(t.revenue_sek/maxR*100).toFixed(1)}%;background:${cols[i%cols.length]}"></div></div>
|
|
<span class="bar-val">${fmtSEK(t.revenue_sek)}</span>
|
|
</div>`).join('');
|
|
|
|
document.querySelector('#revenue-table tbody').innerHTML = types.map((t,i)=>`
|
|
<tr>
|
|
<td style="font-weight:600"><span style="display:inline-block;width:10px;height:10px;border-radius: var(--radius-sm);background:${cols[i%cols.length]};margin-right:8px;vertical-align:middle"></span>${t.type}</td>
|
|
<td>${fmtSEK(t.missions)}</td>
|
|
<td style="font-weight:700">${fmtSEK(t.revenue_sek)} kr</td>
|
|
<td>${fmtSEK(t.avg_per_mission)} kr</td>
|
|
<td><span class="badge badge-orange">${pct(t.share,0)}</span></td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
/* HEALTH */
|
|
async function loadHealth() {
|
|
const d = await fetchOrSeed('https://api.quixzoom.com/api/qz/analytics/health', seedHealth);
|
|
if(!d) return;
|
|
|
|
document.getElementById('health-metrics').innerHTML = [
|
|
{label:'API p50', value:d.api_latency_ms.p50+'ms', color:'var(--green)'},
|
|
{label:'API p95', value:d.api_latency_ms.p95+'ms', color:d.api_latency_ms.p95>250?'var(--orange)':'var(--green)'},
|
|
{label:'API p99', value:d.api_latency_ms.p99+'ms', color:d.api_latency_ms.p99>600?'var(--red)':'var(--orange)'},
|
|
{label:'Uptime 30d', value:d.uptime_pct_30d+'%', color:'var(--teal)'},
|
|
{label:'Error Rate', value:pct(d.error_rate,2), color:d.error_rate>0.005?'var(--orange)':'var(--green)'},
|
|
{label:'Cache Hit', value:pct(d.cache_hit_rate,0), color:'var(--blue)'},
|
|
].map(m=>`<div class="metric-card"><div class="metric-label">${m.label}</div><div class="metric-value" style="color:${m.color}">${m.value}</div></div>`).join('');
|
|
|
|
const latBars = [
|
|
{label:'p50 (median)', val:d.api_latency_ms.p50, max:200},
|
|
{label:'p95', val:d.api_latency_ms.p95, max:500},
|
|
{label:'p99', val:d.api_latency_ms.p99, max:1000},
|
|
{label:'DB latency', val:d.db_latency_ms, max:50},
|
|
];
|
|
document.getElementById('latency-bars').innerHTML = latBars.map(b=>`
|
|
<div class="bar-row" style="margin-bottom:8px">
|
|
<span class="bar-label">${b.label}</span>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${Math.min(100,(b.val/b.max*100)).toFixed(1)}%;background:${b.val/b.max>0.8?'var(--red)':b.val/b.max>0.5?'var(--orange)':'var(--green)'}"></div></div>
|
|
<span class="bar-val">${b.val}ms</span>
|
|
</div>`).join('');
|
|
|
|
document.getElementById('realtime-stats').innerHTML = [
|
|
{label:'Live Zoomers', value:d.active_zoomers_now, icon:''},
|
|
{label:'Aktiva uppdrag', value:d.active_missions_now, icon:''},
|
|
{label:'WebSocket conns', value:d.websocket_conns, icon:''},
|
|
{label:'Queue djup', value:d.queue_depth, icon:''},
|
|
].map(x=>`
|
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:0.5px solid var(--border)">
|
|
<span style="font-size:13px;font-weight:500">${x.icon} ${x.label}</span>
|
|
<span style="font-size:20px;font-weight:800;color:var(--orange)">${x.value}</span>
|
|
</div>`).join('');
|
|
|
|
document.querySelector('#services-table tbody').innerHTML = d.services.map(s=>`
|
|
<tr>
|
|
<td style="font-weight:600">${s.name}</td>
|
|
<td><span class="dot dot-ok"></span><span style="color:var(--green);font-weight:600">Online</span></td>
|
|
<td style="font-weight:700">${s.latency_ms}ms</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
/* LOAD ALL */
|
|
async function loadAll() {
|
|
await Promise.all([
|
|
loadOverview(),
|
|
loadRevMiniChart(),
|
|
loadServicesMini(),
|
|
loadSuccess(),
|
|
loadEarnings(),
|
|
loadFunnel(),
|
|
loadGeo(),
|
|
loadRevenue(),
|
|
loadHealth(),
|
|
]);
|
|
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString('sv-SE');
|
|
}
|
|
|
|
/* AUTO-REFRESH */
|
|
window.addEventListener('load', ()=>{
|
|
setTimeout(loadAll, 80);
|
|
setInterval(loadAll, 30000);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|