Add budget-allocator-server example

Demo MCP App for interactive budget allocation with:
- Slider controls for 5 categories (Marketing, Engineering, etc.)
- Real-time donut chart visualization via Chart.js
- 24-month sparkline trends per category
- Industry benchmark percentile badges
- Company stage selector (Seed through Growth)
- Budget preset buttons ($50K-$500K)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2025-12-04 13:43:34 -06:00
commit f81d17f385
3 changed files with 1648 additions and 0 deletions

View file

@ -0,0 +1,14 @@
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
margin: 0;
padding: 0;
}
code {
font-size: 1em;
}

View file

@ -0,0 +1,459 @@
/* CSS Variables for theming */
:root {
--color-bg: #ffffff;
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-border: #e5e7eb;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-info: #3b82f6;
--slider-track: #e5e7eb;
--slider-thumb: #3b82f6;
--slider-thumb-size: 16px;
--card-bg: #f9fafb;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #111827;
--color-text: #f9fafb;
--color-text-muted: #9ca3af;
--color-border: #374151;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-danger: #f87171;
--color-info: #60a5fa;
--slider-track: #374151;
--slider-thumb: #60a5fa;
--card-bg: #1f2937;
}
}
body {
background: var(--color-bg);
color: var(--color-text);
}
/* App Container - Prefers 600px but shrinks responsively */
.app-container {
display: flex;
flex-direction: column;
height: 600px;
width: 600px;
max-width: 100%;
max-height: 100%;
overflow: hidden;
padding: 12px;
gap: 8px;
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
overflow: hidden;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border);
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.budget-select {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.budget-select:focus {
outline: 2px solid var(--color-info);
outline-offset: 1px;
}
/* Chart Section */
.chart-section {
flex: 0 0 160px;
display: flex;
justify-content: center;
align-items: center;
padding: 4px;
}
#budget-chart {
max-width: 150px;
max-height: 150px;
}
/* Sliders Section */
.sliders-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow: visible;
min-height: 0;
}
/* Slider Row */
.slider-row {
display: grid;
grid-template-columns: 95px 50px minmax(60px, 1fr) 56px 46px;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
background: var(--card-bg);
transition: background-color 0.15s ease;
}
.slider-row.highlighted {
background: var(--color-border);
}
/* Responsive: narrower screens */
@media (max-width: 500px) {
.app-container {
padding: 8px;
gap: 6px;
}
.header {
padding-bottom: 6px;
}
.title {
font-size: 16px;
}
.chart-section {
flex: 0 0 120px;
}
#budget-chart {
max-width: 110px;
max-height: 110px;
}
.slider-row {
grid-template-columns: 85px 40px minmax(30px, 1fr) 48px 38px;
gap: 4px;
padding: 3px 4px;
}
.slider-label {
font-size: 12px;
gap: 4px;
}
.color-dot {
width: 8px;
height: 8px;
}
.sparkline {
width: 32px;
height: 16px;
}
.slider-value .percent {
font-size: 11px;
}
.slider-value .amount {
font-size: 9px;
}
.percentile-badge {
font-size: 9px;
padding: 2px 4px;
}
.status-bar {
font-size: 11px;
padding: 6px 8px;
}
.comparison-bar {
font-size: 10px;
padding: 6px 8px;
flex-wrap: wrap;
gap: 4px;
}
}
/* Very narrow screens: hide sparklines */
@media (max-width: 380px) {
.slider-row {
grid-template-columns: 80px minmax(30px, 1fr) 44px 36px;
}
.sparkline-wrapper {
display: none;
}
.comparison-bar {
flex-direction: column;
align-items: flex-start;
}
}
/* Slider Label */
.slider-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--category-color);
flex-shrink: 0;
}
.label-text {
overflow: hidden;
text-overflow: ellipsis;
}
/* Sparkline */
.sparkline {
width: 50px;
height: 28px;
border-radius: 3px;
background: var(--color-border);
cursor: help;
}
.sparkline-wrapper {
position: relative;
}
.sparkline-wrapper:hover .sparkline-tooltip {
opacity: 1;
visibility: visible;
}
.sparkline-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 5px 10px;
background: var(--color-text);
color: var(--color-bg);
font-size: 12px;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease;
pointer-events: none;
z-index: 10;
margin-bottom: 4px;
}
/* Slider Container - pad left to prevent thumb bleeding into sparkline */
.slider-container {
overflow: visible;
padding: 4px 0 4px calc(var(--slider-thumb-size) / 2);
}
/* Range Slider */
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--slider-track);
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
border-radius: 50%;
background: var(--slider-thumb);
cursor: pointer;
transition: transform 0.1s ease;
border: 2px solid var(--color-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.slider::-moz-range-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
border-radius: 50%;
background: var(--slider-thumb);
cursor: pointer;
border: 2px solid var(--color-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider:focus {
outline: none;
}
.slider:focus::-webkit-slider-thumb {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
/* Slider Value Display */
.slider-value {
text-align: right;
font-variant-numeric: tabular-nums;
}
.slider-value .percent {
display: block;
font-size: 15px;
font-weight: 600;
}
.slider-value .amount {
display: block;
font-size: 12px;
color: var(--color-text-muted);
}
/* Percentile Badge */
.percentile-badge {
font-size: 12px;
font-weight: 500;
padding: 3px 6px;
border-radius: 4px;
text-align: center;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
}
.percentile-icon {
font-size: 8px;
}
.percentile-normal {
color: var(--color-success);
background: rgba(16, 185, 129, 0.15);
}
.percentile-high {
color: var(--color-info);
background: rgba(59, 130, 246, 0.15);
}
.percentile-low {
color: var(--color-warning);
background: rgba(245, 158, 11, 0.15);
}
/* Footer */
.footer {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Status Bar */
.status-bar {
padding: 10px 12px;
border-radius: 6px;
text-align: center;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.status-balanced {
background: rgba(16, 185, 129, 0.15);
color: var(--color-success);
}
.status-warning {
background: rgba(245, 158, 11, 0.15);
color: var(--color-warning);
}
.status-warning.status-over {
background: rgba(239, 68, 68, 0.15);
color: var(--color-danger);
}
.status-icon {
font-size: 12px;
}
/* Comparison Bar */
.comparison-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--card-bg);
border-radius: 6px;
font-size: 12px;
color: var(--color-text-muted);
}
.comparison-highlight {
font-weight: 600;
color: var(--color-text);
}
.stage-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.stage-select {
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text);
font-size: 12px;
cursor: pointer;
}
.stage-select:focus {
outline: 2px solid var(--color-info);
outline-offset: 1px;
}

View file

@ -0,0 +1,628 @@
/**
* Budget Allocator App - Interactive budget allocation with real-time visualization
*/
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
import { Chart, registerables } from "chart.js";
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<string, BenchmarkPercentiles>;
}
interface BudgetConfig {
categories: BudgetCategory[];
presetBudgets: number[];
defaultBudget: number;
currency: string;
currencySymbol: string;
}
interface HistoricalMonth {
month: string;
allocations: Record<string, number>;
}
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<string, number>;
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 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 = `
<label class="slider-label" style="--category-color: ${category.color}">
<span class="color-dot"></span>
<span class="label-text">${category.name}</span>
</label>
<div class="sparkline-wrapper">
<canvas class="sparkline" width="50" height="28"></canvas>
<span class="sparkline-tooltip">${tooltipText}</span>
</div>
<div class="slider-container">
<input
type="range"
class="slider"
min="0"
max="100"
step="1"
value="${allocation}"
/>
</div>
<span class="slider-value">
<span class="percent">${allocation.toFixed(1)}%</span>
<span class="amount">${formatCurrency(amount)}</span>
</span>
<span class="percentile-badge"></span>
`;
// 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 = `<span class="percentile-icon">${getPercentileIcon(percentile)}</span>${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)}
<span class="status-icon">${statusIcon}</span>
`;
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: <span class="comparison-highlight">${maxDeviationCategory.name}</span>
${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;
// 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(new PostMessageTransport(window.parent));