/** * Budget Allocator App - Interactive budget allocation with real-time visualization */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { Chart, registerables } from "chart.js"; import { z } from "zod"; import "./global.css"; import "./mcp-app.css"; // Register Chart.js components Chart.register(...registerables); const log = { info: console.log.bind(console, "[APP]"), error: console.error.bind(console, "[APP]"), }; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface BudgetCategory { id: string; name: string; color: string; defaultPercent: number; } interface BenchmarkPercentiles { p25: number; p50: number; p75: number; } interface StageBenchmark { stage: string; categoryBenchmarks: Record; } interface BudgetConfig { categories: BudgetCategory[]; presetBudgets: number[]; defaultBudget: number; currency: string; currencySymbol: string; } interface HistoricalMonth { month: string; allocations: Record; } interface BudgetAnalytics { history: HistoricalMonth[]; benchmarks: StageBenchmark[]; stages: string[]; defaultStage: string; } interface BudgetDataResponse { config: BudgetConfig; analytics: BudgetAnalytics; } interface AppState { config: BudgetConfig | null; analytics: BudgetAnalytics | null; totalBudget: number; allocations: Map; selectedStage: string; chart: Chart<"doughnut"> | null; } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- const state: AppState = { config: null, analytics: null, totalBudget: 100000, allocations: new Map(), selectedStage: "Series A", chart: null, }; // --------------------------------------------------------------------------- // DOM References // --------------------------------------------------------------------------- const appContainer = document.querySelector(".app-container") as HTMLElement; const budgetSelector = document.getElementById( "budget-selector", ) as HTMLSelectElement; const stageSelector = document.getElementById( "stage-selector", ) as HTMLSelectElement; const slidersContainer = document.getElementById("sliders-container")!; const statusBar = document.getElementById("status-bar")!; const comparisonSummary = document.getElementById("comparison-summary")!; const chartCanvas = document.getElementById( "budget-chart", ) as HTMLCanvasElement; // --------------------------------------------------------------------------- // Utility Functions // --------------------------------------------------------------------------- function formatCurrency(amount: number): string { const symbol = state.config?.currencySymbol ?? "$"; if (amount >= 1000) { return `${symbol}${Math.round(amount / 1000)}K`; } return `${symbol}${amount.toLocaleString()}`; } function formatCurrencyFull(amount: number): string { const symbol = state.config?.currencySymbol ?? "$"; return `${symbol}${amount.toLocaleString()}`; } // --------------------------------------------------------------------------- // Sparkline Rendering (Custom Canvas) // --------------------------------------------------------------------------- function drawSparkline( canvas: HTMLCanvasElement, data: number[], color: string, ): void { const ctx = canvas.getContext("2d"); if (!ctx || data.length < 2) return; const width = canvas.width; const height = canvas.height; const padding = 2; // Clear canvas ctx.clearRect(0, 0, width, height); // Calculate min/max for scaling const min = Math.min(...data) - 2; const max = Math.max(...data) + 2; const range = max - min || 1; // Draw area fill ctx.beginPath(); ctx.moveTo(padding, height - padding); data.forEach((value, i) => { const x = padding + (i / (data.length - 1)) * (width - 2 * padding); const y = height - padding - ((value - min) / range) * (height - 2 * padding); ctx.lineTo(x, y); }); ctx.lineTo(width - padding, height - padding); ctx.closePath(); ctx.fillStyle = `${color}20`; // 12.5% opacity ctx.fill(); // Draw line ctx.beginPath(); data.forEach((value, i) => { const x = padding + (i / (data.length - 1)) * (width - 2 * padding); const y = height - padding - ((value - min) / range) * (height - 2 * padding); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke(); } // --------------------------------------------------------------------------- // Percentile Calculation // --------------------------------------------------------------------------- function calculatePercentile( value: number, benchmarks: BenchmarkPercentiles, ): number { // Interpolate percentile based on value position relative to p25, p50, p75 if (value <= benchmarks.p25) { return 25 * (value / benchmarks.p25); } if (value <= benchmarks.p50) { return ( 25 + 25 * ((value - benchmarks.p25) / (benchmarks.p50 - benchmarks.p25)) ); } if (value <= benchmarks.p75) { return ( 50 + 25 * ((value - benchmarks.p50) / (benchmarks.p75 - benchmarks.p50)) ); } // Above p75 const extraRange = benchmarks.p75 - benchmarks.p50; return 75 + 25 * Math.min(1, (value - benchmarks.p75) / extraRange); } function getPercentileClass(percentile: number): string { if (percentile >= 40 && percentile <= 60) return "percentile-normal"; if (percentile > 60) return "percentile-high"; return "percentile-low"; } function formatPercentileBadge(percentile: number): string { const rounded = Math.round(percentile); if (percentile >= 40 && percentile <= 60) return `${rounded}th`; if (percentile > 60) return `${rounded}th`; return `${rounded}th`; } function getPercentileIcon(percentile: number): string { if (percentile >= 40 && percentile <= 60) return ""; if (percentile > 60) return ""; return ""; } // --------------------------------------------------------------------------- // Chart Initialization // --------------------------------------------------------------------------- function initChart(categories: BudgetCategory[]): Chart<"doughnut"> { const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; return new Chart(chartCanvas, { type: "doughnut", data: { labels: categories.map((c) => c.name), datasets: [ { data: categories.map( (c) => state.allocations.get(c.id) ?? c.defaultPercent, ), backgroundColor: categories.map((c) => c.color), borderWidth: 2, borderColor: isDarkMode ? "#1f2937" : "#ffffff", }, ], }, options: { responsive: true, maintainAspectRatio: true, cutout: "60%", plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => { const pct = ctx.parsed; const amt = (pct / 100) * state.totalBudget; return `${ctx.label}: ${pct.toFixed(1)}% (${formatCurrency(amt)})`; }, }, }, }, onClick: (_event, elements) => { if (elements.length > 0 && state.config) { const index = elements[0].index; focusSlider(state.config.categories[index].id); } }, onHover: (_event, elements) => { if (elements.length > 0 && state.config) { highlightSlider(state.config.categories[elements[0].index].id); } else { clearSliderHighlight(); } }, }, }); } function updateChart(): void { if (!state.chart || !state.config) return; const data = state.config.categories.map( (c) => state.allocations.get(c.id) ?? 0, ); state.chart.data.datasets[0].data = data; state.chart.update("none"); } // --------------------------------------------------------------------------- // Slider Management // --------------------------------------------------------------------------- function createSliderRow( category: BudgetCategory, historyData: number[], ): HTMLElement { const allocation = state.allocations.get(category.id) ?? category.defaultPercent; const amount = (allocation / 100) * state.totalBudget; // Calculate trend info for tooltip const firstVal = historyData[0] ?? 0; const lastVal = historyData[historyData.length - 1] ?? 0; const trendDiff = lastVal - firstVal; const trendArrow = Math.abs(trendDiff) < 0.5 ? "" : trendDiff > 0 ? " +" : " "; const tooltipText = `Past allocations: ${firstVal.toFixed(0)}%${trendArrow}${trendDiff.toFixed(1)}%`; const row = document.createElement("div"); row.className = "slider-row"; row.dataset.categoryId = category.id; row.innerHTML = `
${tooltipText}
${allocation.toFixed(1)}% ${formatCurrency(amount)} `; // Draw sparkline const sparklineCanvas = row.querySelector(".sparkline") as HTMLCanvasElement; drawSparkline(sparklineCanvas, historyData, category.color); // Slider event listener const slider = row.querySelector(".slider") as HTMLInputElement; slider.addEventListener("input", () => { handleSliderChange(category.id, parseFloat(slider.value)); }); return row; } function handleSliderChange(categoryId: string, newPercent: number): void { state.allocations.set(categoryId, newPercent); updateSliderDisplay(categoryId, newPercent); updateChart(); updateStatusBar(); updatePercentileBadge(categoryId); updateComparisonSummary(); } function updateSliderDisplay(categoryId: string, percent: number): void { const row = document.querySelector( `.slider-row[data-category-id="${categoryId}"]`, ); if (!row) return; const amount = (percent / 100) * state.totalBudget; const percentEl = row.querySelector(".percent")!; const amountEl = row.querySelector(".amount")!; percentEl.textContent = `${percent.toFixed(1)}%`; amountEl.textContent = formatCurrency(amount); } function updateAllSliderAmounts(): void { if (!state.config) return; for (const category of state.config.categories) { const percent = state.allocations.get(category.id) ?? 0; updateSliderDisplay(category.id, percent); } } function focusSlider(categoryId: string): void { const slider = document.querySelector( `.slider-row[data-category-id="${categoryId}"] .slider`, ) as HTMLInputElement | null; if (slider) { slider.focus(); highlightSlider(categoryId); } } function highlightSlider(categoryId: string): void { clearSliderHighlight(); const row = document.querySelector( `.slider-row[data-category-id="${categoryId}"]`, ); if (row) { row.classList.add("highlighted"); } } function clearSliderHighlight(): void { document .querySelectorAll(".slider-row.highlighted") .forEach((el) => el.classList.remove("highlighted")); } // --------------------------------------------------------------------------- // Percentile Badge Updates // --------------------------------------------------------------------------- function updatePercentileBadge(categoryId: string): void { if (!state.analytics || !state.config) return; const stageBenchmark = state.analytics.benchmarks.find( (b) => b.stage === state.selectedStage, ); if (!stageBenchmark) return; const category = state.config.categories.find((c) => c.id === categoryId); if (!category) return; const currentAllocation = state.allocations.get(categoryId) ?? category.defaultPercent; const benchmarks = stageBenchmark.categoryBenchmarks[categoryId]; if (!benchmarks) return; const badge = document.querySelector( `.slider-row[data-category-id="${categoryId}"] .percentile-badge`, ); if (!badge) return; const percentile = calculatePercentile(currentAllocation, benchmarks); badge.className = `percentile-badge ${getPercentileClass(percentile)}`; badge.innerHTML = `${getPercentileIcon(percentile)}${formatPercentileBadge(percentile)}`; } function updateAllPercentileBadges(): void { if (!state.config) return; for (const category of state.config.categories) { updatePercentileBadge(category.id); } } // --------------------------------------------------------------------------- // Status Bar // --------------------------------------------------------------------------- function updateStatusBar(): void { const total = Array.from(state.allocations.values()).reduce( (sum, v) => sum + v, 0, ); const allocated = (total / 100) * state.totalBudget; const isBalanced = Math.abs(total - 100) < 0.1; let statusIcon: string; let statusClass: string; if (isBalanced) { statusIcon = ""; statusClass = "status-balanced"; } else if (total > 100) { statusIcon = " Over"; statusClass = "status-warning status-over"; } else { statusIcon = " Under"; statusClass = "status-warning status-under"; } statusBar.innerHTML = ` Allocated: ${formatCurrencyFull(allocated)} / ${formatCurrencyFull(state.totalBudget)} ${statusIcon} `; statusBar.className = `status-bar ${statusClass}`; } // --------------------------------------------------------------------------- // Comparison Summary // --------------------------------------------------------------------------- function updateComparisonSummary(): void { if (!state.analytics || !state.config) return; const stageBenchmark = state.analytics.benchmarks.find( (b) => b.stage === state.selectedStage, ); if (!stageBenchmark) return; // Find most notable deviation let maxDeviation = 0; let maxDeviationCategory: BudgetCategory | null = null; let maxDeviationDirection = ""; for (const category of state.config.categories) { const allocation = state.allocations.get(category.id) ?? category.defaultPercent; const benchmark = stageBenchmark.categoryBenchmarks[category.id]; if (!benchmark) continue; const deviation = allocation - benchmark.p50; if (Math.abs(deviation) > Math.abs(maxDeviation)) { maxDeviation = deviation; maxDeviationCategory = category; maxDeviationDirection = deviation > 0 ? "above" : "below"; } } if (maxDeviationCategory && Math.abs(maxDeviation) > 3) { comparisonSummary.innerHTML = ` vs. Industry: ${maxDeviationCategory.name} ${maxDeviation > 0 ? "" : ""} ${Math.abs(Math.round(maxDeviation))}% ${maxDeviationDirection} avg `; } else { comparisonSummary.textContent = "vs. Industry: similar to peers"; } } // --------------------------------------------------------------------------- // Selector Initialization // --------------------------------------------------------------------------- function initBudgetSelector(presets: number[], defaultBudget: number): void { budgetSelector.innerHTML = ""; for (const amount of presets) { const option = document.createElement("option"); option.value = amount.toString(); option.textContent = formatCurrencyFull(amount); option.selected = amount === defaultBudget; budgetSelector.appendChild(option); } budgetSelector.addEventListener("change", () => { state.totalBudget = parseInt(budgetSelector.value); updateAllSliderAmounts(); updateStatusBar(); }); } function initStageSelector(stages: string[], defaultStage: string): void { stageSelector.innerHTML = ""; for (const stage of stages) { const option = document.createElement("option"); option.value = stage; option.textContent = stage; option.selected = stage === defaultStage; stageSelector.appendChild(option); } stageSelector.addEventListener("change", () => { state.selectedStage = stageSelector.value; updateAllPercentileBadges(); updateComparisonSummary(); }); } // --------------------------------------------------------------------------- // Main Initialization // --------------------------------------------------------------------------- function initializeUI(config: BudgetConfig, analytics: BudgetAnalytics): void { state.config = config; state.analytics = analytics; state.totalBudget = config.defaultBudget; state.selectedStage = analytics.defaultStage; // Initialize allocations with defaults for (const category of config.categories) { state.allocations.set(category.id, category.defaultPercent); } // Initialize selectors initBudgetSelector(config.presetBudgets, config.defaultBudget); initStageSelector(analytics.stages, analytics.defaultStage); // Create slider rows slidersContainer.innerHTML = ""; for (const category of config.categories) { const historyData = analytics.history.map( (h) => h.allocations[category.id] ?? 0, ); const row = createSliderRow(category, historyData); slidersContainer.appendChild(row); } // Initialize chart state.chart = initChart(config.categories); // Update all displays updateAllPercentileBadges(); updateStatusBar(); updateComparisonSummary(); log.info( "UI initialized with", analytics.history.length, "months of history", ); } // --------------------------------------------------------------------------- // App Connection // --------------------------------------------------------------------------- const app = new App({ name: "Budget Allocator", version: "1.0.0" }); app.ontoolresult = (result) => { log.info("Received tool result:", result); const data = result.structuredContent as unknown as BudgetDataResponse; if (data?.config && data?.analytics) { initializeUI(data.config, data.analytics); } }; app.onerror = log.error; function handleHostContextChanged(ctx: McpUiHostContext) { if (ctx.safeAreaInsets) { appContainer.style.paddingTop = `${ctx.safeAreaInsets.top}px`; appContainer.style.paddingRight = `${ctx.safeAreaInsets.right}px`; appContainer.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; appContainer.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; } } app.onhostcontextchanged = handleHostContextChanged; // Register tools for model interaction app.registerTool( "get-allocations", { title: "Get Budget Allocations", description: "Get the current budget allocations including total budget, percentages, and amounts per category", }, async () => { if (!state.config) { return { content: [ { type: "text" as const, text: "Error: Configuration not loaded" }, ], isError: true, }; } const allocations: Record = {}; for (const category of state.config.categories) { const percent = state.allocations.get(category.id) ?? 0; allocations[category.id] = { percent, amount: (percent / 100) * state.totalBudget, }; } const result = { totalBudget: state.totalBudget, currency: state.config.currency, currencySymbol: state.config.currencySymbol, selectedStage: state.selectedStage, allocations, categories: state.config.categories.map((c) => ({ id: c.id, name: c.name, color: c.color, })), }; return { content: [ { type: "text" as const, text: JSON.stringify(result, null, 2) }, ], structuredContent: result, }; }, ); app.registerTool( "set-allocation", { title: "Set Category Allocation", description: "Set the allocation percentage for a specific budget category", inputSchema: z.object({ categoryId: z .string() .describe( "Category ID (e.g., 'marketing', 'engineering', 'operations', 'sales', 'rd')", ), percent: z .number() .min(0) .max(100) .describe("Allocation percentage (0-100)"), }), }, async (args) => { if (!state.config) { return { content: [ { type: "text" as const, text: "Error: Configuration not loaded" }, ], isError: true, }; } const category = state.config.categories.find( (c) => c.id === args.categoryId, ); if (!category) { return { content: [ { type: "text" as const, text: `Error: Category "${args.categoryId}" not found. Available: ${state.config.categories.map((c) => c.id).join(", ")}`, }, ], isError: true, }; } handleSliderChange(args.categoryId, args.percent); // Also update the slider UI const slider = document.querySelector( `.slider-row[data-category-id="${args.categoryId}"] .slider`, ) as HTMLInputElement | null; if (slider) { slider.value = String(args.percent); } const amount = (args.percent / 100) * state.totalBudget; return { content: [ { type: "text" as const, text: `Set ${category.name} allocation to ${args.percent.toFixed(1)}% (${state.config.currencySymbol}${amount.toLocaleString()})`, }, ], }; }, ); app.registerTool( "set-total-budget", { title: "Set Total Budget", description: "Set the total budget amount", inputSchema: z.object({ amount: z.number().positive().describe("Total budget amount"), }), }, async (args) => { if (!state.config) { return { content: [ { type: "text" as const, text: "Error: Configuration not loaded" }, ], isError: true, }; } state.totalBudget = args.amount; // Update the budget selector if this amount is a preset const budgetSelector = document.getElementById( "budget-selector", ) as HTMLSelectElement | null; if (budgetSelector) { const option = Array.from(budgetSelector.options).find( (opt) => parseInt(opt.value) === args.amount, ); if (option) { budgetSelector.value = String(args.amount); } } updateAllSliderAmounts(); updateStatusBar(); updateComparisonSummary(); return { content: [ { type: "text" as const, text: `Total budget set to ${state.config.currencySymbol}${args.amount.toLocaleString()}`, }, ], }; }, ); app.registerTool( "set-company-stage", { title: "Set Company Stage", description: "Set the company stage for benchmark comparison (e.g., 'Seed', 'Series A', 'Series B', 'Growth')", inputSchema: z.object({ stage: z.string().describe("Company stage ID"), }), }, async (args) => { if (!state.analytics) { return { content: [ { type: "text" as const, text: "Error: Analytics not loaded" }, ], isError: true, }; } if (!state.analytics.stages.includes(args.stage)) { return { content: [ { type: "text" as const, text: `Error: Stage "${args.stage}" not found. Available: ${state.analytics.stages.join(", ")}`, }, ], isError: true, }; } state.selectedStage = args.stage; // Update the stage selector UI const stageSelector = document.getElementById( "stage-selector", ) as HTMLSelectElement | null; if (stageSelector) { stageSelector.value = args.stage; } // Update all badges and summary if (state.config) { for (const category of state.config.categories) { updatePercentileBadge(category.id); } updateComparisonSummary(); } return { content: [ { type: "text" as const, text: `Company stage set to "${args.stage}"`, }, ], }; }, ); app.registerTool( "get-benchmark-comparison", { title: "Get Benchmark Comparison", description: "Compare current allocations against industry benchmarks for the selected stage", }, async () => { if (!state.config || !state.analytics) { return { content: [{ type: "text" as const, text: "Error: Data not loaded" }], isError: true, }; } const benchmark = state.analytics.benchmarks.find( (b) => b.stage === state.selectedStage, ); if (!benchmark) { return { content: [ { type: "text" as const, text: `Error: No benchmark data for stage "${state.selectedStage}"`, }, ], isError: true, }; } const comparison: Record< string, { current: number; p25: number; p50: number; p75: number; status: string } > = {}; for (const category of state.config.categories) { const current = state.allocations.get(category.id) ?? 0; const benchmarkData = benchmark.categoryBenchmarks[category.id]; let status = "within range"; if (current < benchmarkData.p25) status = "below p25"; else if (current > benchmarkData.p75) status = "above p75"; comparison[category.id] = { current, p25: benchmarkData.p25, p50: benchmarkData.p50, p75: benchmarkData.p75, status, }; } const result = { stage: state.selectedStage, comparison, }; return { content: [ { type: "text" as const, text: JSON.stringify(result, null, 2) }, ], structuredContent: result, }; }, ); // Handle theme changes window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", () => { if (state.chart && state.config) { state.chart.destroy(); state.chart = initChart(state.config.categories); } }); // Connect to host app.connect().then(() => { const ctx = app.getHostContext(); if (ctx) { handleHostContextChanged(ctx); } });