prospector/designs/markets.html
Natalie 0a5af348dd feat(prospector): align design contract with PWA-only architecture
Add the three missing first-class design prototypes and retire the stale
iOS/OSX-SSO framing so designs/ (the authoritative visual+behavior contract)
matches the current single-PWA reality.

- add designs/campaigns.html (facets/preview/launch; core prospecting feature)
- add designs/markets.html (tour-market stats: peak hours/days, conversion)
- add designs/control.html (GO/PAUSE/AWAY kill-switch, digest, activity, held)
- rewrite designs/index.html: PWA-only hub, drop iOS/OSX/SSO, link all 9 designs
- remove designs/ios-prospector-tab.html (Swift target dropped in bcbd558)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:02:16 -04:00

247 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quinn Prospector • Markets</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--app-font: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, system-ui, sans-serif;
}
body {
font-family: var(--app-font);
font-feature-settings: "kern" "liga" "tnum";
}
.mac-window {
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.6), 0 10px 10px -5px rgb(0 0 0 / 0.4);
border: 1px solid #3f3f46;
}
.text-primary { color: #f1f5f9; }
.text-muted { color: #94a3b8; }
.card { background: #18181b; border: 1px solid #3f3f46; border-radius: 12px; }
.card__title { font-size: 12px; font-weight: 600; color: #cbd5e1; margin-bottom: 10px; }
/* segmented range control — mirror web .seg */
.seg { display: inline-flex; background: #27272a; border-radius: 9999px; padding: 2px; }
.seg__btn { padding: 3px 14px; border-radius: 9999px; font-size: 12px; font-weight: 600; color: #94a3b8; }
.seg__btn--active { background: #3f3f46; color: #fff; }
/* vertical stacked hour chart — mirror web .vchart */
.vchart { display: flex; align-items: flex-end; gap: 3px; height: 130px; }
.vchart__col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; }
.vchart__stack { flex: 1; width: 100%; display: flex; flex-direction: column; justify-content: flex-end; }
.vchart__seg--hold { background: #3f3f46; }
.vchart__seg--send { background: #10b981; }
.vchart__col--peak .vchart__seg--hold { background: #475569; }
.vchart__label { font-size: 8px; color: #64748b; margin-top: 3px; height: 10px; }
/* horizontal bars — mirror web .bars */
.bars__row { display: grid; grid-template-columns: 84px 1fr 28px; align-items: center; gap: 8px; margin-bottom: 5px; font-size: 11px; }
.bars__key { color: #cbd5e1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bars__track { background: #27272a; border-radius: 9999px; height: 8px; overflow: hidden; }
.bars__fill { display: block; height: 8px; background: #10b981; border-radius: 9999px; }
.bars__count { text-align: right; color: #94a3b8; font-family: ui-monospace, monospace; }
/* funnel + highlights */
.funnel { display: flex; gap: 8px; }
.stage { flex: 1; background: #27272a; border-radius: 8px; padding: 10px 8px; text-align: center; }
.stage__value { font-size: 20px; font-weight: 700; }
.stage__value--accent { color: #34d399; }
.stage__label { font-size: 9px; color: #94a3b8; margin-top: 2px; }
.highlight { background: #27272a; border-radius: 8px; padding: 10px 14px; min-width: 150px; }
.highlight__value { font-size: 18px; font-weight: 700; }
.highlight__value--accent { color: #34d399; }
.highlight__label { font-size: 9px; color: #94a3b8; margin-top: 2px; text-transform: uppercase; letter-spacing: .5px; }
</style>
</head>
<body class="bg-[#0a0a0c] flex items-center justify-center min-h-screen p-6 text-[#e2e8f0]">
<div class="mac-window w-[1100px] bg-[#1c1c1f] border border-[#3f3f46] rounded-2xl overflow-hidden shadow-2xl flex flex-col" style="height: 760px;">
<!-- macOS Title Bar -->
<div class="h-9 bg-[#27272a] border-b border-[#3f3f46] flex items-center px-3 gap-x-2 flex-shrink-0">
<div class="flex gap-x-1.5">
<div class="w-[12px] h-[12px] rounded-full bg-[#ff5f57] border border-[#e0443e]"></div>
<div class="w-[12px] h-[12px] rounded-full bg-[#febc2e] border border-[#d9a023]"></div>
<div class="w-[12px] h-[12px] rounded-full bg-[#28c840] border border-[#1a9e2b]"></div>
</div>
<div class="flex-1 text-center">
<span class="text-sm font-medium text-primary">Quinn Prospector — Markets</span>
</div>
<div class="flex items-center gap-x-2 text-[10px]">
<div class="px-2 py-px bg-blue-900/60 text-blue-400 rounded text-[9px] font-mono flex items-center gap-x-1">
<i class="fa-solid fa-clock fa-xs"></i>
<span>tour-market = metro + date window + tz</span>
</div>
</div>
</div>
<!-- Market bar -->
<div class="h-12 bg-[#18181b] border-b border-[#3f3f46] px-4 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-x-3">
<a href="index.html" class="text-muted hover:text-white text-xs"><i class="fa-solid fa-arrow-left"></i> Hub</a>
<label class="flex items-center gap-x-2 text-xs">
<span class="text-muted">Market</span>
<select id="market-select" onchange="renderStats()" class="bg-[#27272a] border border-[#3f3f46] rounded-lg px-3 py-1.5 text-sm"></select>
</label>
</div>
<div class="seg" id="range-seg">
<button class="seg__btn" data-days="7" onclick="setRange(7)">7d</button>
<button class="seg__btn seg__btn--active" data-days="30" onclick="setRange(30)">30d</button>
<button class="seg__btn" data-days="90" onclick="setRange(90)">90d</button>
</div>
</div>
<div class="flex-1 overflow-auto p-4 space-y-4" id="stats-body"></div>
</div>
<script>
// Mirror GET /prospector/markets + /market-stats
const markets = [
{ key: 'NYC', label: 'New York City', tz: 'America/New_York', prospects: 71, active: true },
{ key: 'LA', label: 'Los Angeles', tz: 'America/Los_Angeles', prospects: 28, active: false },
{ key: 'SF', label: 'San Francisco', tz: 'America/Los_Angeles', prospects: 15, active: false },
];
let currentDays = 30;
// Deterministic-ish per-market hour buckets (inbound + booked)
function hoursFor(marketKey) {
const base = marketKey === 'NYC' ? 1.0 : marketKey === 'LA' ? 0.5 : 0.3;
const shape = [0,0,0,0,0,1,2,3,4,4,3,4, 5,4,3,4,6,8,11,13,12,9,6,3];
return shape.map((v, hour) => {
const inbound = Math.round(v * base);
const booked = Math.round(inbound * (hour >= 18 && hour <= 22 ? 0.42 : 0.18));
return { hour, inbound, booked };
});
}
function conversionFor(hours) {
return hours.map((h) => ({ hour: h.hour, total: h.inbound, booked: h.booked, rate: h.inbound ? h.booked / h.inbound : 0 }));
}
const dayShape = [
{ weekday: 1, label: 'Mon', inbound: 14, booked: 3 },
{ weekday: 2, label: 'Tue', inbound: 11, booked: 2 },
{ weekday: 3, label: 'Wed', inbound: 16, booked: 4 },
{ weekday: 4, label: 'Thu', inbound: 22, booked: 6 },
{ weekday: 5, label: 'Fri', inbound: 28, booked: 9 },
{ weekday: 6, label: 'Sat', inbound: 24, booked: 8 },
{ weekday: 0, label: 'Sun', inbound: 12, booked: 3 },
];
function statsFor(marketKey, days) {
const m = markets.find((x) => x.key === marketKey);
const scale = days / 30;
const peakHours = hoursFor(marketKey).map((h) => ({ hour: h.hour, inbound: Math.round(h.inbound * scale * 4), booked: Math.round(h.booked * scale * 4) }));
return {
market: m,
rangeDays: days,
peakHours,
conversionByHour: conversionFor(peakHours),
peakDays: dayShape.map((d) => ({ ...d, inbound: Math.round(d.inbound * scale) })),
funnel: { total: m.prospects, newInRange: Math.round(m.prospects * 0.45 * scale + 4), drafted: Math.round(m.prospects * 0.3 * scale + 3), sent: Math.round(m.prospects * 0.22 * scale + 2), qualified: Math.round(m.prospects * 0.18 * scale + 1) },
bySegment: [{ key: 'dates', count: Math.round(m.prospects * 0.66) }, { key: 'digital', count: Math.round(m.prospects * 0.25) }, { key: 'life', count: Math.round(m.prospects * 0.09) }],
byLocality: marketKey === 'NYC'
? [{ key: 'Williamsburg', count: 24 }, { key: 'Manhattan', count: 19 }, { key: 'Brooklyn', count: 14 }, { key: 'Queens', count: 8 }]
: [{ key: 'Downtown', count: 12 }, { key: 'Westside', count: 9 }, { key: 'Valley', count: 6 }],
};
}
const fmtHour = (h) => { const p = h < 12 ? 'am' : 'pm'; const h12 = h % 12 === 0 ? 12 : h % 12; return `${h12}${p}`; };
const pct = (r) => `${Math.round(r * 100)}%`;
function setRange(d) {
currentDays = d;
document.querySelectorAll('#range-seg .seg__btn').forEach((b) => b.classList.toggle('seg__btn--active', +b.dataset.days === d));
renderStats();
}
function bars(rows) {
const max = Math.max(1, ...rows.map((r) => r.count));
return `<div class="bars">${rows.map((r) => `
<div class="bars__row">
<span class="bars__key">${r.key}</span>
<span class="bars__track"><span class="bars__fill" style="width:${(r.count / max) * 100}%"></span></span>
<span class="bars__count">${r.count}</span>
</div>`).join('')}</div>`;
}
function renderStats() {
const key = document.getElementById('market-select').value;
const s = statsFor(key, currentDays);
const maxIn = Math.max(1, ...s.peakHours.map((h) => h.inbound));
const peakHour = s.peakHours.reduce((best, h) => (h.inbound > 0 && (!best || h.inbound > best.inbound) ? h : best), null);
const best = s.conversionByHour.filter((b) => b.total >= 3).reduce((b1, b2) => (!b1 || b2.rate > b1.rate ? b2 : b1), null);
const maxConv = Math.max(0.001, ...s.conversionByHour.map((b) => b.rate));
const hourCols = s.peakHours.map((h) => `
<div class="vchart__col${peakHour && h.hour === peakHour.hour ? ' vchart__col--peak' : ''}" title="${fmtHour(h.hour)}: ${h.inbound} inbound · ${h.booked} booked">
<div class="vchart__stack">
<div class="vchart__seg vchart__seg--hold" style="height:${((h.inbound - h.booked) / maxIn) * 100}%"></div>
<div class="vchart__seg vchart__seg--send" style="height:${(h.booked / maxIn) * 100}%"></div>
</div>
<div class="vchart__label">${h.hour % 6 === 0 ? h.hour : ''}</div>
</div>`).join('');
const convCols = s.conversionByHour.map((b) => `
<div class="vchart__col" title="${fmtHour(b.hour)}: ${pct(b.rate)} (${b.booked}/${b.total})">
<div class="vchart__stack"><div class="vchart__seg vchart__seg--send" style="height:${(b.rate / maxConv) * 100}%"></div></div>
<div class="vchart__label">${b.hour % 6 === 0 ? b.hour : ''}</div>
</div>`).join('');
const f = s.funnel;
document.getElementById('stats-body').innerHTML = `
<section class="card p-4">
<div class="card__title">${s.market.label} · ${s.market.tz} · last ${s.rangeDays}d</div>
<div class="flex gap-3">
<div class="highlight">
<div class="highlight__value">${peakHour ? fmtHour(peakHour.hour) : '—'}</div>
<div class="highlight__label">Peak inbound hour</div>
</div>
<div class="highlight">
<div class="highlight__value highlight__value--accent">${best ? `${fmtHour(best.hour)} · ${pct(best.rate)}` : '—'}</div>
<div class="highlight__label">Best-converting hour</div>
</div>
</div>
</section>
<section class="card p-4">
<div class="card__title">Peak hours · ${s.market.tz}
<span class="ml-2 text-[10px] font-normal text-muted"><span class="inline-block w-2 h-2 rounded-sm align-middle" style="background:#3f3f46"></span> inbound · <span class="inline-block w-2 h-2 rounded-sm align-middle" style="background:#10b981"></span> booked</span>
</div>
<div class="vchart">${hourCols}</div>
</section>
<section class="card p-4">
<div class="card__title">Conversion by hour</div>
<div class="vchart">${convCols}</div>
</section>
<section class="card p-4">
<div class="card__title">Peak days</div>
${bars(s.peakDays.map((d) => ({ key: d.label, count: d.inbound })))}
</section>
<section class="card p-4">
<div class="card__title">Auto-qualify funnel</div>
<div class="funnel">
<div class="stage"><div class="stage__value">${f.total}</div><div class="stage__label">Prospects</div></div>
<div class="stage"><div class="stage__value">${f.newInRange}</div><div class="stage__label">New ≤${s.rangeDays}d</div></div>
<div class="stage"><div class="stage__value">${f.drafted}</div><div class="stage__label">Drafted</div></div>
<div class="stage"><div class="stage__value">${f.sent}</div><div class="stage__label">Sent</div></div>
<div class="stage"><div class="stage__value stage__value--accent">${f.qualified}</div><div class="stage__label">Qualified</div></div>
</div>
</section>
<section class="card p-4">
<div class="card__title">By band &amp; locality</div>
<div class="grid grid-cols-2 gap-6">
<div>${bars(s.bySegment)}</div>
<div>${bars(s.byLocality)}</div>
</div>
</section>`;
}
window.onload = function () {
const sel = document.getElementById('market-select');
sel.innerHTML = markets.map((m) => `<option value="${m.key}">${m.label} · ${m.prospects} prospect${m.prospects === 1 ? '' : 's'}</option>`).join('');
sel.value = (markets.find((m) => m.active) ?? markets[0]).key;
renderStats();
};
</script>
</body>
</html>