|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Scenario Modeler</title> |
| 4 | +<style> |
| 5 | +:root{--bg:#fff;--bg2:#f9fafb;--bg3:#f3f4f6;--text:#111827;--text2:#6b7280;--text3:#9ca3af;--border:#e5e7eb;--blue:#3b82f6;--green:#10b981;--amber:#f59e0b;--red:#ef4444;--slider-track:#d1d5db;--slider-thumb:#3b82f6} |
| 6 | +@media(prefers-color-scheme:dark){:root{--bg:#111827;--bg2:#1f2937;--bg3:#374151;--text:#f9fafb;--text2:#9ca3af;--text3:#6b7280;--border:#374151;--slider-track:#4b5563;--slider-thumb:#60a5fa}} |
| 7 | +*{margin:0;padding:0;box-sizing:border-box} |
| 8 | +html,body{width:100%;height:100%;font-family:system-ui,-apple-system,sans-serif;font-size:14px;background:var(--bg);color:var(--text);overflow:hidden} |
| 9 | +.main{width:100%;height:100%;display:flex;flex-direction:column} |
| 10 | +.header{height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;border-bottom:1px solid var(--border);flex-shrink:0} |
| 11 | +.header-title{font-size:14px;font-weight:600;white-space:nowrap} |
| 12 | +.header-ctrls{display:flex;align-items:center;gap:6px} |
| 13 | +.template-select,.btn{font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg2);color:var(--text);cursor:pointer} |
| 14 | +.btn:hover{background:var(--bg3)} |
| 15 | +.params{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0} |
| 16 | +.section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text2);margin:0 0 4px 0} |
| 17 | +.slider-row{display:flex;align-items:center;height:22px;gap:8px} |
| 18 | +.slider-label{width:90px;font-size:12px;color:var(--text);flex-shrink:0} |
| 19 | +.slider-input{flex:1;height:4px;-webkit-appearance:none;appearance:none;background:var(--slider-track);border-radius:2px;cursor:pointer} |
| 20 | +.slider-input::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--slider-thumb);cursor:pointer} |
| 21 | +.slider-value{width:45px;font-size:12px;font-weight:500;color:var(--text);text-align:right;flex-shrink:0} |
| 22 | +.chart-section{padding:8px 10px;border-bottom:1px solid var(--border);flex:2;display:flex;flex-direction:column;min-height:0} |
| 23 | +.chart-container{flex:1;position:relative;min-height:0} |
| 24 | +.chart-container canvas{width:100%!important;height:100%!important} |
| 25 | +.chart-legend{display:flex;gap:16px;justify-content:center;font-size:11px;color:var(--text2);padding-top:4px} |
| 26 | +.legend-item{display:flex;align-items:center;gap:4px} |
| 27 | +.legend-color{display:inline-block;width:10px;height:2px;border-radius:1px} |
| 28 | +.metrics{padding:6px 10px;flex:1;display:flex;flex-direction:column;min-height:0} |
| 29 | +.metrics-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:6px} |
| 30 | +.metric-card{background:var(--bg2);border-radius:6px;padding:4px 8px;text-align:center} |
| 31 | +.metric-value{font-size:16px;font-weight:600;color:var(--text)} |
| 32 | +.metric-label{font-size:10px;color:var(--text2);margin-top:2px} |
| 33 | +.green{color:var(--green)}.red{color:var(--red)}.amber{color:var(--amber)} |
| 34 | +.metrics-summary{padding-top:6px;border-top:1px solid var(--border);font-size:11px;color:var(--text2);display:flex;justify-content:space-between} |
| 35 | +.loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--text2);font-size:14px} |
| 36 | +</style></head> |
| 37 | +<body> |
| 38 | +<main class="main" id="app"><div class="loading" id="loading">Connecting to dscode...</div></main> |
| 39 | +<script> |
| 40 | +// ================================================================ |
| 41 | +// Pure JS MCP App — no framework, no external dependencies |
| 42 | +// Canvas API for chart, DOM API for UI |
| 43 | +// ================================================================ |
| 44 | + |
| 45 | +// --- MCP JSON-RPC over postMessage --- |
| 46 | +var reqId = 1, pending = {}, hostCtx = null; |
| 47 | +var templates = [], defaultInputs = {startingMRR:50000,monthlyGrowthRate:5,monthlyChurnRate:3,grossMargin:80,fixedCosts:30000}; |
| 48 | + |
| 49 | +function send(method, params) { |
| 50 | + return new Promise(function(resolve, reject) { |
| 51 | + var id = reqId++; |
| 52 | + pending[id] = {resolve:resolve, reject:reject}; |
| 53 | + window.parent.postMessage({jsonrpc:'2.0',id:id,method:method,params:params},'*'); |
| 54 | + setTimeout(function(){ if(pending[id]){delete pending[id]; reject(new Error('timeout'));} },30000); |
| 55 | + }); |
| 56 | +} |
| 57 | + |
| 58 | +window.addEventListener('message', function(e) { |
| 59 | + var m = e.data; |
| 60 | + if (!m || typeof m !== 'object') return; |
| 61 | + if (m.id !== undefined && (m.result !== undefined || m.error !== undefined)) { |
| 62 | + var p = pending[m.id]; |
| 63 | + if (p) { delete pending[m.id]; m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result); } |
| 64 | + } |
| 65 | + if (m.method) handleNotification(m.method, m.params); |
| 66 | +}); |
| 67 | + |
| 68 | +var notifHandlers = {}; |
| 69 | +function onNotif(method, fn) { |
| 70 | + if (!notifHandlers[method]) notifHandlers[method] = []; |
| 71 | + notifHandlers[method].push(fn); |
| 72 | +} |
| 73 | +function handleNotification(method, params) { |
| 74 | + var hs = notifHandlers[method] || []; |
| 75 | + for (var i = 0; i < hs.length; i++) hs[i](params); |
| 76 | +} |
| 77 | + |
| 78 | +// --- Business Logic --- |
| 79 | +function calcProjections(inp) { |
| 80 | + var r = (inp.monthlyGrowthRate - inp.monthlyChurnRate) / 100; |
| 81 | + var ps = [], cum = 0; |
| 82 | + for (var m = 1; m <= 12; m++) { |
| 83 | + var mrr = inp.startingMRR * Math.pow(1 + r, m); |
| 84 | + var gp = mrr * (inp.grossMargin / 100); |
| 85 | + var np = gp - inp.fixedCosts; |
| 86 | + cum += mrr; |
| 87 | + ps.push({month:m, mrr:mrr, grossProfit:gp, netProfit:np, cumulativeRevenue:cum}); |
| 88 | + } |
| 89 | + return ps; |
| 90 | +} |
| 91 | + |
| 92 | +function calcSummary(ps, inp) { |
| 93 | + var e = ps[11].mrr; |
| 94 | + var tr = 0, tp = 0; |
| 95 | + for (var i = 0; i < ps.length; i++) { tr += ps[i].mrr; tp += ps[i].netProfit; } |
| 96 | + var bp = null; |
| 97 | + for (var i = 0; i < ps.length; i++) { if (ps[i].netProfit >= 0) { bp = ps[i].month; break; } } |
| 98 | + return {endingMRR:e,arr:e*12,totalRevenue:Math.round(tr),totalProfit:Math.round(tp), |
| 99 | + mrrGrowthPct:Math.round(((e-inp.startingMRR)/inp.startingMRR)*1000)/10, |
| 100 | + avgMargin:Math.round((tp/tr)*1000)/10, breakEvenMonth:bp}; |
| 101 | +} |
| 102 | + |
| 103 | +function fmtCurrency(v) { |
| 104 | + var a = Math.abs(v), s = v < 0 ? '-' : ''; |
| 105 | + if (a >= 1e6) return s + '$' + (a/1e6).toFixed(2) + 'M'; |
| 106 | + if (a >= 1e3) return s + '$' + (a/1e3).toFixed(1) + 'K'; |
| 107 | + return s + '$' + Math.round(a); |
| 108 | +} |
| 109 | +function fmtPct(v, sign) { return (sign && v > 0 ? '+' : '') + v.toFixed(1).replace(/\.0$/, '') + '%'; } |
| 110 | + |
| 111 | +// --- Canvas Chart --- |
| 112 | +function drawChart(canvas, userProj, tmplProj, tmplName) { |
| 113 | + var ctx = canvas.getContext('2d'); |
| 114 | + var W = canvas.width = canvas.offsetWidth; |
| 115 | + var H = canvas.height = canvas.offsetHeight; |
| 116 | + var pad = {top:20, right:20, bottom:30, left:50}; |
| 117 | + var pw = W - pad.left - pad.right; |
| 118 | + var ph = H - pad.top - pad.bottom; |
| 119 | + |
| 120 | + ctx.clearRect(0,0,W,H); |
| 121 | + |
| 122 | + // Find max value |
| 123 | + var maxVal = 0; |
| 124 | + for (var i = 0; i < 12; i++) { |
| 125 | + maxVal = Math.max(maxVal, Math.abs(userProj[i].mrr), Math.abs(userProj[i].netProfit)); |
| 126 | + if (tmplProj) maxVal = Math.max(maxVal, Math.abs(tmplProj[i].mrr), Math.abs(tmplProj[i].netProfit)); |
| 127 | + } |
| 128 | + maxVal = Math.ceil(maxVal/5000)*5000; |
| 129 | + |
| 130 | + // Grid |
| 131 | + var isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
| 132 | + var gridColor = isDark ? '#374151' : '#e5e7eb'; |
| 133 | + var textColor = isDark ? '#9ca3af' : '#6b7280'; |
| 134 | + ctx.strokeStyle = gridColor; |
| 135 | + ctx.lineWidth = 0.5; |
| 136 | + var ySteps = 5; |
| 137 | + for (var i = 0; i <= ySteps; i++) { |
| 138 | + var y = pad.top + (ph * i / ySteps); |
| 139 | + ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W-pad.right, y); ctx.stroke(); |
| 140 | + var val = maxVal - (maxVal * i / ySteps); |
| 141 | + ctx.fillStyle = textColor; ctx.font = '10px system-ui'; |
| 142 | + ctx.textAlign = 'right'; |
| 143 | + ctx.fillText(fmtCurrency(val), pad.left - 4, y + 4); |
| 144 | + } |
| 145 | + |
| 146 | + // X labels |
| 147 | + ctx.textAlign = 'center'; |
| 148 | + for (var i = 0; i < 12; i++) { |
| 149 | + var x = pad.left + (pw * i / 11); |
| 150 | + ctx.fillText('M' + (i+1), x, H - pad.bottom + 14); |
| 151 | + } |
| 152 | + |
| 153 | + function toX(i) { return pad.left + (pw * i / 11); } |
| 154 | + function toY(v) { return pad.top + ph - (ph * v / maxVal); } |
| 155 | + |
| 156 | + function drawLine(data, color, dashed) { |
| 157 | + ctx.beginPath(); |
| 158 | + ctx.strokeStyle = color; |
| 159 | + ctx.lineWidth = dashed ? 1.5 : 2; |
| 160 | + if (dashed) ctx.setLineDash([5, 5]); else ctx.setLineDash([]); |
| 161 | + ctx.moveTo(toX(0), toY(data[0])); |
| 162 | + for (var i = 1; i < 12; i++) ctx.lineTo(toX(i), toY(data[i])); |
| 163 | + ctx.stroke(); |
| 164 | + ctx.setLineDash([]); |
| 165 | + } |
| 166 | + |
| 167 | + drawLine(userProj.map(function(p){return p.mrr}), '#3b82f6', false); |
| 168 | + drawLine(userProj.map(function(p){return p.grossProfit}), '#10b981', false); |
| 169 | + drawLine(userProj.map(function(p){return p.netProfit}), '#f59e0b', false); |
| 170 | + |
| 171 | + if (tmplProj) { |
| 172 | + drawLine(tmplProj.map(function(p){return p.mrr}), '#3b82f6', true); |
| 173 | + drawLine(tmplProj.map(function(p){return p.grossProfit}), '#10b981', true); |
| 174 | + drawLine(tmplProj.map(function(p){return p.netProfit}), '#f59e0b', true); |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +// --- DOM Rendering --- |
| 179 | +var currentInputs = {startingMRR:50000,monthlyGrowthRate:5,monthlyChurnRate:3,grossMargin:80,fixedCosts:30000}; |
| 180 | +var selectedTmplId = null; |
| 181 | + |
| 182 | +function render() { |
| 183 | + var proj = calcProjections(currentInputs); |
| 184 | + var summary = calcSummary(proj, currentInputs); |
| 185 | + var tmpl = null; |
| 186 | + for (var i = 0; i < templates.length; i++) { if (templates[i].id === selectedTmplId) { tmpl = templates[i]; break; } } |
| 187 | + |
| 188 | + var tmplOpts = templates.map(function(t){ return '<option value="' + t.id + '"' + (t.id === selectedTmplId ? ' selected' : '') + '>' + t.icon + ' ' + t.name + '</option>'; }).join(''); |
| 189 | + |
| 190 | + document.getElementById('app').innerHTML = |
| 191 | + '<header class="header"><h1 class="header-title">SaaS Scenario Modeler</h1><div class="header-ctrls">' + |
| 192 | + '<select class="template-select" id="tmpl-select"><option value="">Compare to...</option>' + tmplOpts + '</select>' + |
| 193 | + (tmpl ? '<button class="btn" id="load-btn">Load</button>' : '') + |
| 194 | + '<button class="btn" id="reset-btn">Reset</button></div></header>' + |
| 195 | + '<section class="params"><h2 class="section-title">Parameters</h2>' + |
| 196 | + sliderRow('Starting MRR', 'mrr', currentInputs.startingMRR, 10000, 500000, 5000, fmtCurrency) + |
| 197 | + sliderRow('Growth Rate', 'grow', currentInputs.monthlyGrowthRate, 0, 20, 0.5, function(v){return fmtPct(v)}) + |
| 198 | + sliderRow('Churn Rate', 'churn', currentInputs.monthlyChurnRate, 0, 15, 0.5, function(v){return fmtPct(v)}) + |
| 199 | + sliderRow('Gross Margin', 'margin', currentInputs.grossMargin, 50, 95, 5, function(v){return fmtPct(v)}) + |
| 200 | + sliderRow('Fixed Costs', 'costs', currentInputs.fixedCosts, 5000, 200000, 5000, fmtCurrency) + |
| 201 | + '</section>' + |
| 202 | + '<section class="chart-section"><h2 class="section-title">12-Month Projection</h2>' + |
| 203 | + '<div class="chart-container"><canvas id="chart"></canvas></div>' + |
| 204 | + '<div class="chart-legend"><span class="legend-item"><span class="legend-color" style="background:#3b82f6"></span> MRR</span>' + |
| 205 | + '<span class="legend-item"><span class="legend-color" style="background:#10b981"></span> Gross Profit</span>' + |
| 206 | + '<span class="legend-item"><span class="legend-color" style="background:#f59e0b"></span> Net Profit</span>' + |
| 207 | + (tmpl ? '<span class="legend-item" style="color:var(--text3)">(dashed = ' + tmpl.name + ')</span>' : '') + |
| 208 | + '</div></section>' + |
| 209 | + '<section class="metrics"><div class="metrics-grid">' + |
| 210 | + metricCard('End MRR', fmtCurrency(summary.endingMRR)) + |
| 211 | + metricCard('Total Revenue', fmtCurrency(summary.totalRevenue)) + |
| 212 | + metricCard('Total Profit', fmtCurrency(summary.totalProfit), summary.totalProfit >= 0 ? 'green' : 'red') + |
| 213 | + '</div>' + |
| 214 | + '<div class="metrics-summary"><span>Break-even: <b>' + (summary.breakEvenMonth ? 'Month ' + summary.breakEvenMonth : 'Not achieved') + '</b></span>' + |
| 215 | + '<span>MRR Growth: <b class="' + (summary.mrrGrowthPct >= 0 ? 'green' : 'red') + '">' + fmtPct(summary.mrrGrowthPct) + '</b></span></div>' + |
| 216 | + '</section>'; |
| 217 | + |
| 218 | + bindEvents(); |
| 219 | + var c = document.getElementById('chart'); |
| 220 | + if (c) drawChart(c, proj, tmpl ? tmpl.projections : null, tmpl ? tmpl.name : null); |
| 221 | +} |
| 222 | + |
| 223 | +function sliderRow(label, name, value, min, max, step, format) { |
| 224 | + return '<div class="slider-row"><span class="slider-label">' + label + '</span>' + |
| 225 | + '<input type="range" class="slider-input" id="sl-' + name + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + value + '">' + |
| 226 | + '<span class="slider-value" id="sv-' + name + '">' + format(value) + '</span></div>'; |
| 227 | +} |
| 228 | + |
| 229 | +function metricCard(label, value, cls) { |
| 230 | + return '<div class="metric-card"><div class="metric-value' + (cls ? ' ' + cls : '') + '">' + value + '</div><div class="metric-label">' + label + '</div></div>'; |
| 231 | +} |
| 232 | + |
| 233 | +function bindEvents() { |
| 234 | + function bind(id, evt, fn) { var el = document.getElementById(id); if (el) el.addEventListener(evt, fn); } |
| 235 | + bind('sl-mrr', 'input', function(e){ currentInputs.startingMRR = Number(e.target.value); document.getElementById('sv-mrr').textContent = fmtCurrency(currentInputs.startingMRR); render(); }); |
| 236 | + bind('sl-grow', 'input', function(e){ currentInputs.monthlyGrowthRate = Number(e.target.value); document.getElementById('sv-grow').textContent = fmtPct(currentInputs.monthlyGrowthRate); render(); }); |
| 237 | + bind('sl-churn', 'input', function(e){ currentInputs.monthlyChurnRate = Number(e.target.value); document.getElementById('sv-churn').textContent = fmtPct(currentInputs.monthlyChurnRate); render(); }); |
| 238 | + bind('sl-margin', 'input', function(e){ currentInputs.grossMargin = Number(e.target.value); document.getElementById('sv-margin').textContent = fmtPct(currentInputs.grossMargin); render(); }); |
| 239 | + bind('sl-costs', 'input', function(e){ currentInputs.fixedCosts = Number(e.target.value); document.getElementById('sv-costs').textContent = fmtCurrency(currentInputs.fixedCosts); render(); }); |
| 240 | + bind('tmpl-select', 'change', function(e){ selectedTmplId = e.target.value || null; render(); }); |
| 241 | + bind('load-btn', 'click', function(){ |
| 242 | + var tmpl = null; |
| 243 | + for (var i = 0; i < templates.length; i++) { if (templates[i].id === selectedTmplId) { tmpl = templates[i]; break; } } |
| 244 | + if (tmpl) { currentInputs = Object.assign({}, tmpl.parameters); selectedTmplId = null; render(); } |
| 245 | + }); |
| 246 | + bind('reset-btn', 'click', function(){ currentInputs = Object.assign({}, defaultInputs); selectedTmplId = null; render(); }); |
| 247 | +} |
| 248 | + |
| 249 | +// --- MCP App Lifecycle --- |
| 250 | +async function init() { |
| 251 | + var r = await send('ui/initialize', { |
| 252 | + protocolVersion:'2026-01-26', |
| 253 | + appCapabilities:{}, |
| 254 | + clientInfo:{name:'Scenario Modeler',version:'1.0.0'} |
| 255 | + }); |
| 256 | + hostCtx = r.hostContext || {}; |
| 257 | + window.parent.postMessage({jsonrpc:'2.0',method:'ui/notifications/initialized',params:{}},'*'); |
| 258 | + |
| 259 | + onNotif('ui/notifications/tool-result', function(params) { |
| 260 | + var data = params.structuredContent || {}; |
| 261 | + if (data.templates) templates = data.templates; |
| 262 | + if (data.defaultInputs) defaultInputs = data.defaultInputs; |
| 263 | + if (defaultInputs && !data.customInputs) currentInputs = Object.assign({}, defaultInputs); |
| 264 | + render(); |
| 265 | + }); |
| 266 | + |
| 267 | + render(); |
| 268 | +} |
| 269 | + |
| 270 | +init().catch(function(e){ document.getElementById('loading').textContent = 'Error: ' + e.message; }); |
| 271 | +</script> |
| 272 | +</body> |
| 273 | +</html> |
0 commit comments