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:
parent
c711b1aac4
commit
f81d17f385
3 changed files with 1648 additions and 0 deletions
14
examples/budget-allocator-server/src/global.css
Normal file
14
examples/budget-allocator-server/src/global.css
Normal 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;
|
||||
}
|
||||
459
examples/budget-allocator-server/src/mcp-app.css
Normal file
459
examples/budget-allocator-server/src/mcp-app.css
Normal 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;
|
||||
}
|
||||
628
examples/budget-allocator-server/src/mcp-app.ts
Normal file
628
examples/budget-allocator-server/src/mcp-app.ts
Normal 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));
|
||||
Loading…
Reference in a new issue