Files
boc/quixzoom-landing-fixed/ios-readiness-dashboard.html
T
Bernt 6989a98d75 feat: Passwordless cross-device authentication
- 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
2026-07-07 07:11:50 +00:00

795 lines
30 KiB
HTML

<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>iOS Readiness — quiXzoom</title>
<style>
/* iOS 26 / Apple Native — Dark Mode */
:root {
--bg: #000000;
--bg-secondary: #1c1c1e;
--bg-tertiary: #2c2c2e;
--bg-grouped: #1c1c1e;
--separator: rgba(84,84,88,0.65);
--label: #ffffff;
--label-secondary: rgba(235,235,245,0.6);
--label-tertiary: rgba(235,235,245,0.3);
--tint: #007AFF;
--green: #30D158;
--red: #FF453A;
--yellow: #FFD60A;
--orange: #FF9F0A;
--purple: #BF5AF2;
--fill: rgba(118,118,128,0.24);
--fill-secondary: rgba(118,118,128,0.16);
--radius: 12px;
--radius-lg: 18px;
--radius-sm: 8px;
--font: -apple-system, "SF Pro Display", "Helvetica Neue", sans-serif;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--label);
min-height: 100dvh;
padding-top: calc(var(--safe-top) + 0px);
padding-bottom: calc(var(--safe-bottom) + 40px);
overscroll-behavior-y: contain;
}
/* Nav Bar */
.nav-bar {
position: sticky;
top: 0;
z-index: 100;
background: rgba(242,242,247,0.82);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
border-bottom: 0.5px solid var(--separator);
padding: 14px 20px 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-title { font-size: 17px; font-weight: 600; letter-spacing: -0.4px; }
.nav-right { display: flex; align-items: center; gap: 10px; }
.nav-time { font-size: 13px; color: var(--label-secondary); font-variant-numeric: tabular-nums; }
.refresh-btn {
background: var(--fill);
border: none;
color: var(--tint);
font-size: 13px;
font-weight: 500;
font-family: var(--font);
padding: 6px 12px;
border-radius: var(--radius-xl);
cursor: pointer;
transition: opacity 0.15s;
}
.refresh-btn:active { opacity: 0.6; }
/* Hero Verdict */
.verdict-hero {
margin: 20px 16px 0;
border-radius: var(--radius-lg);
padding: 24px 20px;
display: flex;
align-items: center;
gap: 16px;
position: relative;
overflow: hidden;
transition: background 0.4s;
}
.verdict-hero.ready { background: rgba(48,209,88,0.15); border: 1px solid rgba(48,209,88,0.3); }
.verdict-hero.partial { background: rgba(255,214,10,0.12); border: 1px solid rgba(255,214,10,0.3); }
.verdict-hero.not-ready { background: rgba(255,69,58,0.12); border: 1px solid rgba(255,69,58,0.3); }
.verdict-hero.loading { background: var(--bg-secondary); border: 1px solid var(--separator); }
.verdict-icon { font-size: 52px; line-height: 1; flex-shrink: 0; }
.verdict-text {}
.verdict-question {
font-size: 13px;
font-weight: 500;
color: var(--label-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.verdict-answer {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.5px;
line-height: 1.1;
}
.verdict-hero.ready .verdict-answer { color: var(--green); }
.verdict-hero.partial .verdict-answer { color: var(--yellow); }
.verdict-hero.not-ready .verdict-answer { color: var(--red); }
.verdict-hero.loading .verdict-answer { color: var(--label-secondary); }
.verdict-sub {
font-size: 14px;
color: var(--label-secondary);
margin-top: 4px;
}
/* Stat Grid */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
padding: 16px 16px 4px;
}
.stat-card {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 14px 10px;
text-align: center;
position: relative;
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
}
.stat-card.green::after { background: var(--green); }
.stat-card.red::after { background: var(--red); }
.stat-card.yellow::after { background: var(--yellow); }
.stat-card.blue::after { background: var(--tint); }
.stat-num {
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.5px;
line-height: 1;
margin-bottom: 4px;
}
.stat-card.green .stat-num { color: var(--green); }
.stat-card.red .stat-num { color: var(--red); }
.stat-card.yellow .stat-num { color: var(--yellow); }
.stat-card.blue .stat-num { color: var(--tint); }
.stat-lbl {
font-size: 10px;
font-weight: 500;
color: var(--label-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* Section */
.section { margin-top: 28px; }
.section-hdr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 8px;
}
.section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--label-secondary);
}
.section-badge {
font-size: 13px;
font-weight: 500;
color: var(--label-tertiary);
}
/* List */
.list {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
margin: 0 16px;
overflow: hidden;
}
.list-row {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 16px;
border-bottom: 0.5px solid var(--separator);
}
.list-row:last-child { border-bottom: none; }
.row-icon {
width: 32px; height: 32px;
border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.row-icon.green { background: rgba(48,209,88,0.18); }
.row-icon.red { background: rgba(255,69,58,0.18); }
.row-icon.yellow { background: rgba(255,214,10,0.18); }
.row-icon.blue { background: rgba(0,122,255,0.18); }
.row-icon.grey { background: var(--fill); }
.row-body { flex: 1; min-width: 0; }
.row-title {
font-size: 15px;
font-weight: 500;
color: var(--label);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-sub {
font-size: 13px;
color: var(--label-secondary);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-right { flex-shrink: 0; text-align: right; }
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--radius-xl);
}
.pill-green { background: rgba(48,209,88,0.18); color: var(--green); }
.pill-red { background: rgba(255,69,58,0.18); color: var(--red); }
.pill-yellow { background: rgba(255,214,10,0.18); color: var(--yellow); }
.pill-blue { background: rgba(0,122,255,0.18); color: var(--tint); }
.pill-grey { background: var(--fill); color: var(--label-secondary); }
.dot { width: 7px; height: 7px; border-radius: var(--radius-full); }
.dot-green { background: var(--green); }
.dot-red { background: var(--red); }
.dot-yellow { background: var(--yellow); }
.dot-grey { background: var(--label-tertiary); }
/* Progress Bar */
.progress-wrap { padding: 0 16px; }
.progress-bar-bg {
background: var(--fill);
border-radius: var(--radius-sm);
height: 8px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar-fill {
height: 100%;
border-radius: var(--radius-sm);
transition: width 0.6s ease;
background: var(--green);
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--label-secondary);
margin-top: 6px;
}
/* Blocker */
.blocker-card {
background: rgba(255,69,58,0.08);
border: 1px solid rgba(255,69,58,0.25);
border-radius: var(--radius);
margin: 0 16px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: flex-start;
}
.blocker-card + .blocker-card { margin-top: 10px; }
.blocker-icon { font-size: 20px; flex-shrink: 0; margin-top: 1px; }
.blocker-body {}
.blocker-title { font-size: 15px; font-weight: 600; color: var(--red); }
.blocker-desc { font-size: 13px; color: var(--label-secondary); margin-top: 3px; }
.blocker-owner { font-size: 12px; color: var(--label-tertiary); margin-top: 4px; }
/* Warning */
.warning-card {
background: rgba(255,214,10,0.08);
border: 1px solid rgba(255,214,10,0.25);
border-radius: var(--radius);
margin: 0 16px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: flex-start;
}
.warning-card + .warning-card { margin-top: 10px; }
.warning-icon { font-size: 20px; flex-shrink: 0; }
.warning-body {}
.warning-title { font-size: 15px; font-weight: 600; color: var(--yellow); }
.warning-desc { font-size: 13px; color: var(--label-secondary); margin-top: 3px; }
/* Deploy checklist */
.check-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 0.5px solid var(--separator);
}
.check-item:last-child { border-bottom: none; }
.check-box {
width: 22px; height: 22px;
border-radius: var(--radius-full);
border: 2px solid var(--separator);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 13px;
margin-top: 1px;
}
.check-box.done { background: var(--green); border-color: var(--green); color: #000; }
.check-box.warn { background: var(--yellow); border-color: var(--yellow); color: #000; }
.check-box.fail { background: var(--red); border-color: var(--red); color: #fff; }
.check-box.pending { border-color: var(--label-tertiary); }
.check-body { flex: 1; }
.check-title { font-size: 15px; font-weight: 500; color: var(--label); }
.check-sub { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
/* Footer */
.footer {
text-align: center;
padding: 32px 20px 12px;
font-size: 12px;
color: var(--label-tertiary);
}
/* Spinner */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--fill);
border-top-color: var(--tint);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pulsing { animation: pulse 1.5s ease-in-out infinite; }
@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>
<h1 style="position:absolute;left:-9999px;">iOS Readiness Dashboard</h1>
<!-- Nav Bar -->
<div class="nav-bar">
<div class="nav-title"> iOS Readiness</div>
<div class="nav-right">
<span class="nav-time" id="navTime"></span>
<button class="refresh-btn" onclick="runCheck()">↻ Refresh</button>
</div>
</div>
<!-- Hero Verdict -->
<div class="verdict-hero loading" id="verdictHero">
<div class="verdict-icon pulsing" id="verdictIcon"></div>
<div class="verdict-text">
<div class="verdict-question">Är backend redo för iOS-release?</div>
<div class="verdict-answer" id="verdictAnswer">Kontrollerar…</div>
<div class="verdict-sub" id="verdictSub">Hämtar status från alla tjänster</div>
</div>
</div>
<!-- Stat Grid -->
<div class="stat-grid">
<div class="stat-card green">
<div class="stat-num" id="statOk"></div>
<div class="stat-lbl">API OK</div>
</div>
<div class="stat-card red">
<div class="stat-num" id="statFail"></div>
<div class="stat-lbl">API Fel</div>
</div>
<div class="stat-card yellow">
<div class="stat-num" id="statBlockers"></div>
<div class="stat-lbl">Blockers</div>
</div>
<div class="stat-card blue">
<div class="stat-num" id="statDeploy"></div>
<div class="stat-lbl">Deploy %</div>
</div>
</div>
<!-- API Health Section -->
<div class="section">
<div class="section-hdr">
<span class="section-title">API Health</span>
<span class="section-badge" id="apiSummary">Kontrollerar…</span>
</div>
<div class="list" id="apiList">
<div class="list-row">
<div class="row-icon grey"><span class="spinner"></span></div>
<div class="row-body"><div class="row-title">Hämtar endpoints…</div></div>
</div>
</div>
</div>
<!-- Backend Features -->
<div class="section">
<div class="section-hdr">
<span class="section-title">Backend Features</span>
<span class="section-badge">iOS critical</span>
</div>
<div class="list" id="featureList">
<!-- filled by JS -->
</div>
</div>
<!-- Blockers -->
<div class="section">
<div class="section-hdr">
<span class="section-title"> Kända blockers</span>
<span class="section-badge" id="blockerCount"></span>
</div>
<div id="blockerList"></div>
</div>
<!-- Warnings -->
<div class="section">
<div class="section-hdr">
<span class="section-title"><svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg> Varningar</span>
<span class="section-badge" id="warningCount"></span>
</div>
<div id="warningList"></div>
</div>
<!-- Deployment Checklist -->
<div class="section">
<div class="section-hdr">
<span class="section-title">Deployment Checklist</span>
<span class="section-badge" id="deployProgress"></span>
</div>
<div class="progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="deployBar" style="width:0%"></div>
</div>
<div class="progress-label">
<span>Framsteg mot June 5</span>
<span id="deployPct">0%</span>
</div>
</div>
<div style="height:12px"></div>
<div class="list" id="deployList"></div>
</div>
<div class="footer">
quiXzoom iOS Readiness Dashboard · <span id="footerTime"></span>
</div>
<script>
// Data: Backend Features
const FEATURES = [
{ name: 'BankID Authentication', icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="7" width="10" height="7" rx="1" stroke="#666" stroke-width="1.5"/><path d="M5 7V5C5 3.3 6.3 2 8 2C9.7 2 11 3.3 11 5V7" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="10.5" r="1" fill="#666"/></svg>', status: 'done', note: 'Staging verifierat' },
{ name: 'Zoomer Registration', icon: '', status: 'done', note: 'POST /api/quixzoom/zoomers' },
{ name: 'Mission CRUD', 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>', status: 'done', note: 'Full create/read/update' },
{ name: 'Geolocation Matching', icon: '', status: 'done', note: 'Radius-sökning aktiv' },
{ name: 'Real-time Push (APNs)', icon: '', status: 'warn', note: 'APNs cert upphör aug 2026' },
{ name: 'Earnings & Payouts', icon: '', status: 'done', note: 'Beräkningar OK, utbetalning pending' },
{ name: 'KYC Document Upload', icon: '', status: 'done', note: 'S3 + validering OK' },
{ name: 'Rating & Reviews', icon: '', status: 'done', note: 'Bi-directional ratings' },
{ name: 'Portfolio / Media', icon: '', status: 'done', note: 'Upload + CDN aktiv' },
{ name: 'Stripe Payouts', icon: '', status: 'warn', note: 'Testläge — ej prod-nyckel' },
{ name: 'DAC7 Reporting', icon: '', status: 'done', note: 'Automatisk generering OK' },
{ name: 'GDPR / Data Deletion', icon: '', status: 'pending', note: 'Legal review pågår' },
];
// Data: Blockers
const BLOCKERS = [
{
icon: '',
title: 'Stripe Production Keys saknas',
desc: 'Utbetalningar kan ej köras i prod-miljö utan live Stripe-nycklar.',
owner: 'Ägare: Erik / Ops · Deadline: June 1'
},
{
icon: '',
title: 'APNs Production Certificate',
desc: 'Push-notiser till iOS kräver prod APNs-certifikat i staging-testet.',
owner: 'Ägare: iOS team · Deadline: May 30'
},
{
icon: '<span class="flag-se">SE</span>',
title: 'BankID Prod-avtal ej signerat',
desc: 'Testmiljö körs mot Sandbox. Prod-avtal med Finansiell ID-Teknik AB krävs.',
owner: 'Ägare: Erik · Deadline: May 28'
},
];
// Data: Warnings
const WARNINGS = [
{
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M9 2L5 9H8L7 14L11 7H8L9 2Z" fill="#f59e0b"/></svg>',
title: 'Rate limits ej konfigurerade för iOS',
desc: 'API-rate-limiting behöver tunas för mobila request-mönster.'
},
{
icon: '',
title: 'App Store metadata ej uppladdad',
desc: 'Screenshots, beskrivning och metadata saknas i App Store Connect.'
},
{
icon: '',
title: 'SSL-cert auto-renewal ej verifierat',
desc: 'Kontrollera att certbot renew fungerar för amos.wavult.com.'
},
];
// Data: Deploy Checklist
const DEPLOY_ITEMS = [
{ title: 'API endpoints testade mot iOS simulator', sub: 'Backend', status: 'done' },
{ title: 'BankID Sandbox-integration verifierad', sub: 'Backend', status: 'done' },
{ title: 'Zoomer onboarding flow live', sub: 'Backend', status: 'done' },
{ title: 'Mission create/accept flow live', sub: 'Backend', status: 'done' },
{ title: 'Earnings beräkning verifierad', sub: 'Backend', status: 'done' },
{ title: 'KYC upload + validering live', sub: 'Backend', status: 'done' },
{ title: 'BankID Production-avtal signerat', sub: 'Legal', status: 'fail' },
{ title: 'Stripe Production Keys konfigurerade', sub: 'Ops', status: 'fail' },
{ title: 'APNs Production Certificate installerat', sub: 'iOS', status: 'fail' },
{ title: 'Load test körd (1000 concurrent users)', sub: 'QA', status: 'pending' },
{ title: 'App Store metadata uppladdad', sub: 'Mktg', status: 'pending' },
{ title: 'TestFlight externa testers (25 st)', sub: 'iOS', status: 'pending' },
{ title: 'Privacy Policy publicerad', sub: 'Legal', status: 'done' },
{ title: 'Terms of Service publicerad', sub: 'Legal', status: 'done' },
{ title: 'GDPR Data Deletion verifierad', sub: 'Legal', status: 'pending' },
];
// Endpoints att kontrollera
const ENDPOINTS = [
{ label: 'Zoomer List', method: 'GET', url: 'https://api.quixzoom.com/api/qz/zoomers', critical: true },
{ label: 'Zoomer Profile', method: 'GET', url: 'https://api.quixzoom.com/api/qz/zoomers/zoomer-001/profile', critical: true },
{ label: 'Mission List', method: 'GET', url: 'https://api.quixzoom.com/api/qz/missions', critical: true },
{ label: 'Mission Stats', method: 'GET', url: 'https://api.quixzoom.com/api/qz/missions/stats', critical: false },
{ label: 'Zoomer Earnings', method: 'GET', url: 'https://api.quixzoom.com/api/qz/zoomers/zoomer-001/earnings?period=month', critical: true },
{ label: 'Zoomer Portfolio', method: 'GET', url: 'https://api.quixzoom.com/api/qz/zoomers/zoomer-001/portfolio', critical: false },
{ label: 'Health Check', method: 'GET', url: '/api/health', critical: true },
];
// State
let results = {};
// Render helpers
function statusClass(s) {
return s === 'done' ? 'green' : s === 'warn' ? 'yellow' : s === 'fail' ? 'red' : 'grey';
}
function statusIcon(s) {
return s === 'done' ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' : s === 'warn' ? '!' : s === 'fail' ? '' : '';
}
function statusPill(s, label) {
const cls = s === 'done' ? 'pill-green' : s === 'warn' ? 'pill-yellow' : s === 'fail' ? 'pill-red' : 'pill-grey';
const dot = `<span class="dot dot-${statusClass(s)}"></span>`;
return `<span class="status-pill ${cls}">${dot} ${label}</span>`;
}
// Render static sections
function renderFeatures() {
const el = document.getElementById('featureList');
el.innerHTML = FEATURES.map(f => `
<div class="list-row">
<div class="row-icon ${statusClass(f.status)}">${f.icon}</div>
<div class="row-body">
<div class="row-title">${f.name}</div>
<div class="row-sub">${f.note}</div>
</div>
<div class="row-right">
${statusPill(f.status, f.status === 'done' ? 'Klar' : f.status === 'warn' ? 'Varning' : 'Pending')}
</div>
</div>
`).join('');
}
function renderBlockers() {
const el = document.getElementById('blockerList');
document.getElementById('blockerCount').textContent = BLOCKERS.length + ' aktiva';
el.innerHTML = BLOCKERS.map(b => `
<div class="blocker-card">
<div class="blocker-icon">${b.icon}</div>
<div class="blocker-body">
<div class="blocker-title">${b.title}</div>
<div class="blocker-desc">${b.desc}</div>
<div class="blocker-owner">${b.owner}</div>
</div>
</div>
`).join('');
}
function renderWarnings() {
const el = document.getElementById('warningList');
document.getElementById('warningCount').textContent = WARNINGS.length + ' st';
el.innerHTML = WARNINGS.map(w => `
<div class="warning-card">
<div class="warning-icon">${w.icon}</div>
<div class="warning-body">
<div class="warning-title">${w.title}</div>
<div class="warning-desc">${w.desc}</div>
</div>
</div>
`).join('');
}
function renderDeploy() {
const done = DEPLOY_ITEMS.filter(i => i.status === 'done').length;
const total = DEPLOY_ITEMS.length;
const pct = Math.round(done / total * 100);
document.getElementById('deployProgress').textContent = `${done}/${total} klara`;
document.getElementById('deployPct').textContent = `${pct}%`;
document.getElementById('deployBar').style.width = pct + '%';
document.getElementById('statDeploy').textContent = pct + '%';
const el = document.getElementById('deployList');
el.innerHTML = DEPLOY_ITEMS.map(item => `
<div class="check-item">
<div class="check-box ${item.status === 'done' ? 'done' : item.status === 'fail' ? 'fail' : item.status === 'warn' ? 'warn' : 'pending'}">
${item.status === 'done' ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' : item.status === 'fail' ? '' : item.status === 'warn' ? '!' : ''}
</div>
<div class="check-body">
<div class="check-title">${item.title}</div>
<div class="check-sub">${item.sub}</div>
</div>
</div>
`).join('');
}
// Check an endpoint
async function checkEndpoint(ep) {
const t0 = Date.now();
try {
const r = await fetch(ep.url, { method: ep.method, signal: AbortSignal.timeout(5000) });
return { ok: r.ok, status: r.status, ms: Date.now() - t0 };
} catch (e) {
return { ok: false, status: 0, ms: Date.now() - t0, error: e.message };
}
}
// Main check
async function runCheck() {
// Reset
document.getElementById('verdictHero').className = 'verdict-hero loading';
document.getElementById('verdictIcon').textContent = '⏳';
document.getElementById('verdictIcon').classList.add('pulsing');
document.getElementById('verdictAnswer').textContent = 'Kontrollerar…';
document.getElementById('verdictSub').textContent = 'Hämtar status från alla tjänster';
document.getElementById('statOk').textContent = '—';
document.getElementById('statFail').textContent = '—';
document.getElementById('statBlockers').textContent = '—';
document.getElementById('apiSummary').textContent = 'Kontrollerar…';
// Render API list with spinners
const apiList = document.getElementById('apiList');
apiList.innerHTML = ENDPOINTS.map((ep, i) => `
<div class="list-row" id="ep-row-${i}">
<div class="row-icon grey" id="ep-icon-${i}"><span class="spinner"></span></div>
<div class="row-body">
<div class="row-title">${ep.label} <span style="font-size:11px;color:var(--label-tertiary);font-family:monospace">${ep.method} ${ep.url}</span></div>
<div class="row-sub" id="ep-sub-${i}">Kontrollerar…</div>
</div>
<div class="row-right" id="ep-right-${i}"><span class="status-pill pill-grey"><span class="dot dot-grey"></span> —</span></div>
</div>
`).join('');
// Check all
const checks = await Promise.all(ENDPOINTS.map(checkEndpoint));
let okCount = 0, failCount = 0;
checks.forEach((res, i) => {
const ep = ENDPOINTS[i];
const iconEl = document.getElementById(`ep-icon-${i}`);
const subEl = document.getElementById(`ep-sub-${i}`);
const rightEl = document.getElementById(`ep-right-${i}`);
if (res.ok) {
okCount++;
const color = ep.critical ? 'green' : 'blue';
iconEl.className = `row-icon ${color}`;
iconEl.textContent = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
subEl.textContent = `HTTP ${res.status} · ${res.ms}ms`;
rightEl.innerHTML = `<span class="status-pill pill-green"><span class="dot dot-green"></span> OK</span>`;
} else {
failCount++;
iconEl.className = 'row-icon red';
iconEl.textContent = '';
subEl.textContent = res.error ? `Error: ${res.error}` : `HTTP ${res.status} · ${res.ms}ms`;
rightEl.innerHTML = `<span class="status-pill pill-red"><span class="dot dot-red"></span> FEL</span>`;
}
});
document.getElementById('statOk').textContent = okCount;
document.getElementById('statFail').textContent = failCount;
document.getElementById('statBlockers').textContent = BLOCKERS.length;
document.getElementById('apiSummary').textContent = `${okCount}/${ENDPOINTS.length} OK`;
// Verdict
const hero = document.getElementById('verdictHero');
const icon = document.getElementById('verdictIcon');
const ans = document.getElementById('verdictAnswer');
const sub = document.getElementById('verdictSub');
icon.classList.remove('pulsing');
const criticalFailing = ENDPOINTS.filter((ep, i) => ep.critical && !checks[i].ok).length;
const hardBlockers = BLOCKERS.length;
if (failCount === 0 && hardBlockers === 0) {
hero.className = 'verdict-hero ready';
icon.textContent = '<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>';
ans.textContent = 'JA — Backend redo!';
sub.textContent = `Alla ${ENDPOINTS.length} API:er OK · Inga blockers · Klar för iOS`;
} else if (criticalFailing > 0 || hardBlockers >= 2) {
hero.className = 'verdict-hero not-ready';
icon.textContent = '';
ans.textContent = 'NEJ — Ej release-redo';
sub.textContent = `${criticalFailing} kritiska API-fel · ${hardBlockers} blockers kvar`;
} else {
hero.className = 'verdict-hero partial';
icon.textContent = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg>';
ans.textContent = 'DELVIS — Åtgärder krävs';
sub.textContent = `API:er OK · ${hardBlockers} blockers kvar att lösa`;
}
updateTime();
}
function updateTime() {
const now = new Date();
const str = now.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
document.getElementById('navTime').textContent = str;
document.getElementById('footerTime').textContent = now.toLocaleString('sv-SE');
}
// Init
renderFeatures();
renderBlockers();
renderWarnings();
renderDeploy();
runCheck();
// Auto-refresh every 60s
setInterval(runCheck, 60_000);
// Clock tick
setInterval(() => {
const str = new Date().toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
document.getElementById('navTime').textContent = str;
}, 1000);
</script>
</body>
</html>