Add scenario-modeler-server example
Demo MCP App for SaaS financial planning with: - Interactive sliders for MRR, growth, churn, margin, and costs - 12-month projection charts (MRR, Gross Profit, Net Profit) - 5 pre-built scenario templates (Bootstrapped, VC Rocketship, etc.) - Template comparison overlay for strategy analysis - Light/dark theme support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f81d17f385
commit
79fa10097b
16 changed files with 1743 additions and 0 deletions
50
examples/scenario-modeler-server/README.md
Normal file
50
examples/scenario-modeler-server/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Example: SaaS Scenario Modeler
|
||||
|
||||
A React-based demo MCP App that lets users adjust SaaS business parameters and see real-time 12-month projections of revenue, costs, and profitability with comparison against pre-built scenario templates.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive Parameters**: 5 sliders for Starting MRR, Growth Rate, Churn Rate, Gross Margin, and Fixed Costs
|
||||
- **12-Month Projections**: Line chart showing MRR, Gross Profit, and Net Profit over time
|
||||
- **Scenario Templates**: 5 pre-built business strategies (Bootstrapped, VC Rocketship, Cash Cow, Turnaround, Efficient Growth)
|
||||
- **Template Comparison**: Overlay dashed lines to compare your scenario against any template
|
||||
- **Summary Metrics**: Key metrics including Ending MRR, Total Revenue, Total Profit, and break-even month
|
||||
- **Theme Support**: Adapts to light/dark mode preferences
|
||||
|
||||
## Running
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Build and start the server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The server will listen on `http://localhost:3001/mcp`.
|
||||
|
||||
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server (`server.ts`)
|
||||
|
||||
Exposes a single `get-scenario-data` tool that returns:
|
||||
|
||||
- 5 pre-built scenario templates with parameters, projections, and summaries
|
||||
- Default input values for the sliders
|
||||
- Optionally computes custom projections when `customInputs` are provided
|
||||
|
||||
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
|
||||
|
||||
### App (`src/`)
|
||||
|
||||
- Built with React for reactive slider updates and derived state management
|
||||
- Uses Chart.js for the line chart visualization
|
||||
- All projection calculations run client-side for instant slider feedback
|
||||
- Components: `SliderRow`, `MetricCard`, `ProjectionChart`
|
||||
- Template comparison shown as dashed overlay lines on the chart
|
||||
12
examples/scenario-modeler-server/mcp-app.html
Normal file
12
examples/scenario-modeler-server/mcp-app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SaaS Scenario Modeler</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/mcp-app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
examples/scenario-modeler-server/package.json
Normal file
35
examples/scenario-modeler-server/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "scenario-modeler-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "INPUT=mcp-app.html vite build",
|
||||
"watch": "INPUT=mcp-app.html vite build --watch",
|
||||
"serve": "bun server.ts",
|
||||
"start": "NODE_ENV=development npm run build && npm run serve",
|
||||
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "../..",
|
||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
}
|
||||
}
|
||||
400
examples/scenario-modeler-server/server.ts
Normal file
400
examples/scenario-modeler-server/server.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import cors from "cors";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { RESOURCE_URI_META_KEY } from "../../dist/src/app";
|
||||
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
|
||||
const DIST_DIR = path.join(import.meta.dirname, "dist");
|
||||
|
||||
// ============================================================================
|
||||
// Schemas - types are derived from these using z.infer
|
||||
// ============================================================================
|
||||
|
||||
const ScenarioInputsSchema = z.object({
|
||||
startingMRR: z.number(),
|
||||
monthlyGrowthRate: z.number(),
|
||||
monthlyChurnRate: z.number(),
|
||||
grossMargin: z.number(),
|
||||
fixedCosts: z.number(),
|
||||
});
|
||||
|
||||
const MonthlyProjectionSchema = z.object({
|
||||
month: z.number(),
|
||||
mrr: z.number(),
|
||||
grossProfit: z.number(),
|
||||
netProfit: z.number(),
|
||||
cumulativeRevenue: z.number(),
|
||||
});
|
||||
|
||||
const ScenarioSummarySchema = z.object({
|
||||
endingMRR: z.number(),
|
||||
arr: z.number(),
|
||||
totalRevenue: z.number(),
|
||||
totalProfit: z.number(),
|
||||
mrrGrowthPct: z.number(),
|
||||
avgMargin: z.number(),
|
||||
breakEvenMonth: z.number().nullable(),
|
||||
});
|
||||
|
||||
const ScenarioTemplateSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
icon: z.string(),
|
||||
parameters: ScenarioInputsSchema,
|
||||
projections: z.array(MonthlyProjectionSchema),
|
||||
summary: ScenarioSummarySchema,
|
||||
keyInsight: z.string(),
|
||||
});
|
||||
|
||||
const GetScenarioDataInputSchema = z.object({
|
||||
customInputs: ScenarioInputsSchema.optional().describe(
|
||||
"Custom scenario parameters to compute projections for",
|
||||
),
|
||||
});
|
||||
|
||||
const GetScenarioDataOutputSchema = z.object({
|
||||
templates: z.array(ScenarioTemplateSchema),
|
||||
defaultInputs: ScenarioInputsSchema,
|
||||
customProjections: z.array(MonthlyProjectionSchema).optional(),
|
||||
customSummary: ScenarioSummarySchema.optional(),
|
||||
});
|
||||
|
||||
// Types derived from schemas
|
||||
type ScenarioInputs = z.infer<typeof ScenarioInputsSchema>;
|
||||
type MonthlyProjection = z.infer<typeof MonthlyProjectionSchema>;
|
||||
type ScenarioSummary = z.infer<typeof ScenarioSummarySchema>;
|
||||
type ScenarioTemplate = z.infer<typeof ScenarioTemplateSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Calculations
|
||||
// ============================================================================
|
||||
|
||||
function calculateProjections(inputs: ScenarioInputs): MonthlyProjection[] {
|
||||
const {
|
||||
startingMRR,
|
||||
monthlyGrowthRate,
|
||||
monthlyChurnRate,
|
||||
grossMargin,
|
||||
fixedCosts,
|
||||
} = inputs;
|
||||
|
||||
const netGrowthRate = (monthlyGrowthRate - monthlyChurnRate) / 100;
|
||||
const projections: MonthlyProjection[] = [];
|
||||
let cumulativeRevenue = 0;
|
||||
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
const mrr = startingMRR * Math.pow(1 + netGrowthRate, month);
|
||||
const grossProfit = mrr * (grossMargin / 100);
|
||||
const netProfit = grossProfit - fixedCosts;
|
||||
cumulativeRevenue += mrr;
|
||||
|
||||
projections.push({
|
||||
month,
|
||||
mrr,
|
||||
grossProfit,
|
||||
netProfit,
|
||||
cumulativeRevenue,
|
||||
});
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
function calculateSummary(
|
||||
projections: MonthlyProjection[],
|
||||
inputs: ScenarioInputs,
|
||||
): ScenarioSummary {
|
||||
const endingMRR = projections[11].mrr;
|
||||
const arr = endingMRR * 12;
|
||||
const totalRevenue = projections.reduce((sum, p) => sum + p.mrr, 0);
|
||||
const totalProfit = projections.reduce((sum, p) => sum + p.netProfit, 0);
|
||||
const mrrGrowthPct =
|
||||
((endingMRR - inputs.startingMRR) / inputs.startingMRR) * 100;
|
||||
const avgMargin = (totalProfit / totalRevenue) * 100;
|
||||
|
||||
const breakEvenProjection = projections.find((p) => p.netProfit >= 0);
|
||||
const breakEvenMonth = breakEvenProjection?.month ?? null;
|
||||
|
||||
return {
|
||||
endingMRR,
|
||||
arr,
|
||||
totalRevenue,
|
||||
totalProfit,
|
||||
mrrGrowthPct,
|
||||
avgMargin,
|
||||
breakEvenMonth,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateScenario(inputs: ScenarioInputs) {
|
||||
const projections = calculateProjections(inputs);
|
||||
const summary = calculateSummary(projections, inputs);
|
||||
return { projections, summary };
|
||||
}
|
||||
|
||||
function buildTemplate(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
icon: string,
|
||||
parameters: ScenarioInputs,
|
||||
keyInsight: string,
|
||||
): ScenarioTemplate {
|
||||
const { projections, summary } = calculateScenario(parameters);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
parameters,
|
||||
projections,
|
||||
summary,
|
||||
keyInsight,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pre-defined Templates
|
||||
// ============================================================================
|
||||
|
||||
const SCENARIO_TEMPLATES: ScenarioTemplate[] = [
|
||||
buildTemplate(
|
||||
"bootstrapped",
|
||||
"Bootstrapped Growth",
|
||||
"Low burn, steady growth, path to profitability",
|
||||
"🌱",
|
||||
{
|
||||
startingMRR: 30000,
|
||||
monthlyGrowthRate: 4,
|
||||
monthlyChurnRate: 2,
|
||||
grossMargin: 85,
|
||||
fixedCosts: 20000,
|
||||
},
|
||||
"Profitable by month 1, but slower scale",
|
||||
),
|
||||
buildTemplate(
|
||||
"vc-rocketship",
|
||||
"VC Rocketship",
|
||||
"High burn, explosive growth, raise more later",
|
||||
"🚀",
|
||||
{
|
||||
startingMRR: 100000,
|
||||
monthlyGrowthRate: 15,
|
||||
monthlyChurnRate: 5,
|
||||
grossMargin: 70,
|
||||
fixedCosts: 150000,
|
||||
},
|
||||
"Loses money early but ends at 3x MRR",
|
||||
),
|
||||
buildTemplate(
|
||||
"cash-cow",
|
||||
"Cash Cow",
|
||||
"Mature product, high margin, stable revenue",
|
||||
"🐄",
|
||||
{
|
||||
startingMRR: 80000,
|
||||
monthlyGrowthRate: 2,
|
||||
monthlyChurnRate: 1,
|
||||
grossMargin: 90,
|
||||
fixedCosts: 40000,
|
||||
},
|
||||
"Consistent profitability, low risk",
|
||||
),
|
||||
buildTemplate(
|
||||
"turnaround",
|
||||
"Turnaround",
|
||||
"Fighting churn, rebuilding product-market fit",
|
||||
"🔄",
|
||||
{
|
||||
startingMRR: 60000,
|
||||
monthlyGrowthRate: 6,
|
||||
monthlyChurnRate: 8,
|
||||
grossMargin: 75,
|
||||
fixedCosts: 50000,
|
||||
},
|
||||
"Negative net growth requires urgent action",
|
||||
),
|
||||
buildTemplate(
|
||||
"efficient-growth",
|
||||
"Efficient Growth",
|
||||
"Balanced approach with sustainable economics",
|
||||
"⚖️",
|
||||
{
|
||||
startingMRR: 50000,
|
||||
monthlyGrowthRate: 8,
|
||||
monthlyChurnRate: 3,
|
||||
grossMargin: 80,
|
||||
fixedCosts: 35000,
|
||||
},
|
||||
"Good growth with path to profitability",
|
||||
),
|
||||
];
|
||||
|
||||
const DEFAULT_INPUTS: ScenarioInputs = {
|
||||
startingMRR: 50000,
|
||||
monthlyGrowthRate: 5,
|
||||
monthlyChurnRate: 3,
|
||||
grossMargin: 80,
|
||||
fixedCosts: 30000,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Formatters for text output
|
||||
// ============================================================================
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
const absValue = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
if (absValue >= 1_000_000) {
|
||||
return `${sign}$${(absValue / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
if (absValue >= 1_000) {
|
||||
return `${sign}$${(absValue / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return `${sign}$${Math.round(absValue)}`;
|
||||
}
|
||||
|
||||
function formatScenarioSummary(
|
||||
summary: ScenarioSummary,
|
||||
label: string,
|
||||
): string {
|
||||
return [
|
||||
`${label}:`,
|
||||
` Ending MRR: ${formatCurrency(summary.endingMRR)}`,
|
||||
` ARR: ${formatCurrency(summary.arr)}`,
|
||||
` Total Revenue: ${formatCurrency(summary.totalRevenue)}`,
|
||||
` Total Profit: ${formatCurrency(summary.totalProfit)}`,
|
||||
` MRR Growth: ${summary.mrrGrowthPct.toFixed(1)}%`,
|
||||
` Break-even: ${summary.breakEvenMonth ? `Month ${summary.breakEvenMonth}` : "Not achieved"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Server
|
||||
// ============================================================================
|
||||
|
||||
const server = new McpServer({
|
||||
name: "SaaS Scenario Modeler",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Register tool and resource
|
||||
{
|
||||
const resourceUri = "ui://scenario-modeler/mcp-app.html";
|
||||
|
||||
server.registerTool(
|
||||
"get-scenario-data",
|
||||
{
|
||||
title: "Get Scenario Data",
|
||||
description:
|
||||
"Returns SaaS scenario templates and optionally computes custom projections for given inputs",
|
||||
inputSchema: GetScenarioDataInputSchema.shape,
|
||||
outputSchema: GetScenarioDataOutputSchema.shape,
|
||||
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
|
||||
},
|
||||
async (args: {
|
||||
customInputs?: ScenarioInputs;
|
||||
}): Promise<CallToolResult> => {
|
||||
const customScenario = args.customInputs
|
||||
? calculateScenario(args.customInputs)
|
||||
: undefined;
|
||||
|
||||
const text = [
|
||||
"SaaS Scenario Modeler",
|
||||
"=".repeat(40),
|
||||
"",
|
||||
"Available Templates:",
|
||||
...SCENARIO_TEMPLATES.map(
|
||||
(t) => ` ${t.icon} ${t.name}: ${t.description}`,
|
||||
),
|
||||
"",
|
||||
customScenario
|
||||
? formatScenarioSummary(customScenario.summary, "Custom Scenario")
|
||||
: "Use customInputs parameter to compute projections for a specific scenario.",
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
structuredContent: {
|
||||
templates: SCENARIO_TEMPLATES,
|
||||
defaultInputs: DEFAULT_INPUTS,
|
||||
customProjections: customScenario?.projections,
|
||||
customSummary: customScenario?.summary,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
resourceUri,
|
||||
resourceUri,
|
||||
{ description: "SaaS Scenario Modeler UI" },
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
const html = await fs.readFile(
|
||||
path.join(DIST_DIR, "mcp-app.html"),
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Express Server
|
||||
// ============================================================================
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error("Error handling MCP request:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = app.listen(PORT, () => {
|
||||
console.log(
|
||||
`SaaS Scenario Modeler Server listening on http://localhost:${PORT}/mcp`,
|
||||
);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
console.log("\nShutting down...");
|
||||
httpServer.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
variant?: "default" | "positive" | "negative";
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
variant = "default",
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<div className={`metric-card metric-card--${variant}`}>
|
||||
<span className="metric-value">{value}</span>
|
||||
<span className="metric-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import { useTheme } from "../hooks/useTheme.ts";
|
||||
import type { MonthlyProjection } from "../types.ts";
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
interface ProjectionChartProps {
|
||||
userProjections: MonthlyProjection[];
|
||||
templateProjections: MonthlyProjection[] | null;
|
||||
templateName?: string;
|
||||
}
|
||||
|
||||
export function ProjectionChart({
|
||||
userProjections,
|
||||
templateProjections,
|
||||
templateName,
|
||||
}: ProjectionChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartRef = useRef<Chart | null>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
// Create chart on mount, rebuild on theme change
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const textColor = theme === "dark" ? "#9ca3af" : "#6b7280";
|
||||
const gridColor = theme === "dark" ? "#374151" : "#e5e7eb";
|
||||
|
||||
chartRef.current = new Chart(canvasRef.current, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: Array.from({ length: 12 }, (_, i) => `M${i + 1}`),
|
||||
datasets: [
|
||||
// User scenario (solid lines)
|
||||
{
|
||||
label: "MRR",
|
||||
borderColor: "#3b82f6",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
data: [],
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "Gross Profit",
|
||||
borderColor: "#10b981",
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
data: [],
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: "Net Profit",
|
||||
borderColor: "#f59e0b",
|
||||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||||
data: [],
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
// Template comparison (dashed lines)
|
||||
{
|
||||
label: "Template MRR",
|
||||
borderColor: "#3b82f6",
|
||||
borderDash: [5, 5],
|
||||
data: [],
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
label: "Template Gross",
|
||||
borderColor: "#10b981",
|
||||
borderDash: [5, 5],
|
||||
data: [],
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
label: "Template Net",
|
||||
borderColor: "#f59e0b",
|
||||
borderDash: [5, 5],
|
||||
data: [],
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: theme === "dark" ? "#1f2937" : "#ffffff",
|
||||
titleColor: theme === "dark" ? "#f9fafb" : "#111827",
|
||||
bodyColor: theme === "dark" ? "#9ca3af" : "#6b7280",
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
if (value === null) return "";
|
||||
const formatted =
|
||||
Math.abs(value) >= 1000
|
||||
? `$${(value / 1000).toFixed(1)}K`
|
||||
: `$${value.toFixed(0)}`;
|
||||
return `${context.dataset.label}: ${formatted}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
callback: (value) => {
|
||||
const num = Number(value);
|
||||
if (Math.abs(num) >= 1000) {
|
||||
return `$${(num / 1000).toFixed(0)}K`;
|
||||
}
|
||||
return `$${num}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => chartRef.current?.destroy();
|
||||
}, [theme]);
|
||||
|
||||
// Update data when projections change
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const chart = chartRef.current;
|
||||
chart.data.datasets[0].data = userProjections.map((p) => p.mrr);
|
||||
chart.data.datasets[1].data = userProjections.map((p) => p.grossProfit);
|
||||
chart.data.datasets[2].data = userProjections.map((p) => p.netProfit);
|
||||
|
||||
if (templateProjections) {
|
||||
chart.data.datasets[3].data = templateProjections.map((p) => p.mrr);
|
||||
chart.data.datasets[4].data = templateProjections.map(
|
||||
(p) => p.grossProfit,
|
||||
);
|
||||
chart.data.datasets[5].data = templateProjections.map((p) => p.netProfit);
|
||||
chart.data.datasets[3].hidden = false;
|
||||
chart.data.datasets[4].hidden = false;
|
||||
chart.data.datasets[5].hidden = false;
|
||||
} else {
|
||||
chart.data.datasets[3].hidden = true;
|
||||
chart.data.datasets[4].hidden = true;
|
||||
chart.data.datasets[5].hidden = true;
|
||||
}
|
||||
|
||||
chart.update("none"); // Skip animation for smoother slider updates
|
||||
}, [userProjections, templateProjections]);
|
||||
|
||||
return (
|
||||
<section className="chart-section">
|
||||
<h2 className="section-title">12-Month Projection</h2>
|
||||
<div className="chart-container">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
<div className="chart-legend">
|
||||
<span className="legend-item">
|
||||
<span style={{ color: "#3b82f6" }}>--</span> MRR
|
||||
</span>
|
||||
<span className="legend-item">
|
||||
<span style={{ color: "#10b981" }}>--</span> Gross Profit
|
||||
</span>
|
||||
<span className="legend-item">
|
||||
<span style={{ color: "#f59e0b" }}>--</span> Net Profit
|
||||
</span>
|
||||
{templateProjections && (
|
||||
<span className="legend-item legend-template">
|
||||
(dashed = {templateName})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
interface SliderRowProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
format: (v: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function SliderRow({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
format,
|
||||
onChange,
|
||||
}: SliderRowProps) {
|
||||
return (
|
||||
<div className="slider-row">
|
||||
<label className="slider-label">{label}</label>
|
||||
<input
|
||||
type="range"
|
||||
className="slider-input"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="slider-value">{format(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
examples/scenario-modeler-server/src/global.css
Normal file
14
examples/scenario-modeler-server/src/global.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 1em;
|
||||
}
|
||||
25
examples/scenario-modeler-server/src/hooks/useTheme.ts
Normal file
25
examples/scenario-modeler-server/src/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export function useTheme(): Theme {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setTheme(e.matches ? "dark" : "light");
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
72
examples/scenario-modeler-server/src/lib/calculations.ts
Normal file
72
examples/scenario-modeler-server/src/lib/calculations.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type {
|
||||
ScenarioInputs,
|
||||
MonthlyProjection,
|
||||
ScenarioSummary,
|
||||
} from "../types.ts";
|
||||
|
||||
export function calculateProjections(
|
||||
inputs: ScenarioInputs,
|
||||
): MonthlyProjection[] {
|
||||
const {
|
||||
startingMRR,
|
||||
monthlyGrowthRate,
|
||||
monthlyChurnRate,
|
||||
grossMargin,
|
||||
fixedCosts,
|
||||
} = inputs;
|
||||
|
||||
// Net growth rate (can be negative if churn > growth)
|
||||
const netGrowthRate = (monthlyGrowthRate - monthlyChurnRate) / 100;
|
||||
|
||||
const projections: MonthlyProjection[] = [];
|
||||
let cumulativeRevenue = 0;
|
||||
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
// MRR with compound growth
|
||||
const mrr = startingMRR * Math.pow(1 + netGrowthRate, month);
|
||||
|
||||
// Profit calculations
|
||||
const grossProfit = mrr * (grossMargin / 100);
|
||||
const netProfit = grossProfit - fixedCosts;
|
||||
|
||||
// Cumulative revenue
|
||||
cumulativeRevenue += mrr;
|
||||
|
||||
projections.push({
|
||||
month,
|
||||
mrr,
|
||||
grossProfit,
|
||||
netProfit,
|
||||
cumulativeRevenue,
|
||||
});
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
export function calculateSummary(
|
||||
projections: MonthlyProjection[],
|
||||
inputs: ScenarioInputs,
|
||||
): ScenarioSummary {
|
||||
const endingMRR = projections[11].mrr;
|
||||
const arr = endingMRR * 12;
|
||||
const totalRevenue = projections.reduce((sum, p) => sum + p.mrr, 0);
|
||||
const totalProfit = projections.reduce((sum, p) => sum + p.netProfit, 0);
|
||||
const mrrGrowthPct =
|
||||
((endingMRR - inputs.startingMRR) / inputs.startingMRR) * 100;
|
||||
const avgMargin = (totalProfit / totalRevenue) * 100;
|
||||
|
||||
// Find first month where netProfit >= 0
|
||||
const breakEvenProjection = projections.find((p) => p.netProfit >= 0);
|
||||
const breakEvenMonth = breakEvenProjection?.month ?? null;
|
||||
|
||||
return {
|
||||
endingMRR,
|
||||
arr,
|
||||
totalRevenue,
|
||||
totalProfit,
|
||||
mrrGrowthPct,
|
||||
avgMargin,
|
||||
breakEvenMonth,
|
||||
};
|
||||
}
|
||||
44
examples/scenario-modeler-server/src/lib/formatters.ts
Normal file
44
examples/scenario-modeler-server/src/lib/formatters.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Format a number as currency with abbreviated suffixes.
|
||||
* Examples: $50K, $1.2M, -$30K
|
||||
*/
|
||||
export function formatCurrency(value: number): string {
|
||||
const absValue = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
|
||||
if (absValue >= 1_000_000) {
|
||||
const formatted = (absValue / 1_000_000).toFixed(
|
||||
absValue >= 10_000_000 ? 1 : 2,
|
||||
);
|
||||
// Remove trailing zeros after decimal
|
||||
const cleaned = formatted.replace(/\.?0+$/, "");
|
||||
return `${sign}$${cleaned}M`;
|
||||
}
|
||||
|
||||
if (absValue >= 1_000) {
|
||||
const formatted = (absValue / 1_000).toFixed(absValue >= 100_000 ? 0 : 1);
|
||||
const cleaned = formatted.replace(/\.?0+$/, "");
|
||||
return `${sign}$${cleaned}K`;
|
||||
}
|
||||
|
||||
return `${sign}$${Math.round(absValue)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as a percentage.
|
||||
* Examples: 5%, -2.5%, +12%
|
||||
*/
|
||||
export function formatPercent(value: number, showSign = false): string {
|
||||
const sign = showSign && value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(1).replace(/\.0$/, "")}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency for slider display (always show K suffix for consistency).
|
||||
*/
|
||||
export function formatCurrencySlider(value: number): string {
|
||||
if (value >= 1_000) {
|
||||
return `$${(value / 1_000).toFixed(0)}K`;
|
||||
}
|
||||
return `$${value}`;
|
||||
}
|
||||
335
examples/scenario-modeler-server/src/mcp-app.css
Normal file
335
examples/scenario-modeler-server/src/mcp-app.css
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/* ============================================================================
|
||||
CSS Variables for Light/Dark Theme
|
||||
============================================================================ */
|
||||
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f9fafb;
|
||||
--bg-tertiary: #f3f4f6;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--text-muted: #9ca3af;
|
||||
--border-color: #e5e7eb;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-green: #10b981;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--slider-track: #d1d5db;
|
||||
--slider-thumb: #3b82f6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #111827;
|
||||
--bg-secondary: #1f2937;
|
||||
--bg-tertiary: #374151;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--border-color: #374151;
|
||||
--slider-track: #4b5563;
|
||||
--slider-thumb: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Main Layout
|
||||
============================================================================ */
|
||||
|
||||
.main {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Header (40px)
|
||||
============================================================================ */
|
||||
|
||||
.header {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Parameters Section (140px)
|
||||
============================================================================ */
|
||||
|
||||
.parameters-section {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
width: 90px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--slider-track);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--slider-thumb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-input::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--slider-thumb);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
width: 45px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Chart Section (230px)
|
||||
============================================================================ */
|
||||
|
||||
.chart-section {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-yours {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.legend-template {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Metrics Section (remaining space ~150px)
|
||||
============================================================================ */
|
||||
|
||||
.metrics-section {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.metrics-comparison {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metrics-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metrics-column h3 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.metrics-column.template .metrics-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metric-card--positive .metric-value {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.metric-card--negative .metric-value {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.metrics-summary {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summary-value.positive {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.summary-value.negative {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Loading State
|
||||
============================================================================ */
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
336
examples/scenario-modeler-server/src/mcp-app.tsx
Normal file
336
examples/scenario-modeler-server/src/mcp-app.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { useApp } from "@modelcontextprotocol/ext-apps/react";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { StrictMode, useState, useMemo, useCallback } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SliderRow } from "./components/SliderRow.tsx";
|
||||
import { MetricCard } from "./components/MetricCard.tsx";
|
||||
import { ProjectionChart } from "./components/ProjectionChart.tsx";
|
||||
import { calculateProjections, calculateSummary } from "./lib/calculations.ts";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
formatCurrencySlider,
|
||||
} from "./lib/formatters.ts";
|
||||
import type {
|
||||
ScenarioInputs,
|
||||
ScenarioTemplate,
|
||||
ScenarioSummary,
|
||||
} from "./types.ts";
|
||||
import "./global.css";
|
||||
import "./mcp-app.css";
|
||||
|
||||
interface CallToolResultData {
|
||||
templates?: ScenarioTemplate[];
|
||||
defaultInputs?: ScenarioInputs;
|
||||
}
|
||||
|
||||
/** Extract templates and defaultInputs from tool result structuredContent */
|
||||
function extractResultData(result: CallToolResult): CallToolResultData {
|
||||
if (!result.structuredContent) return {};
|
||||
const { templates, defaultInputs } =
|
||||
result.structuredContent as CallToolResultData;
|
||||
return { templates, defaultInputs };
|
||||
}
|
||||
|
||||
const APP_INFO = { name: "SaaS Scenario Modeler", version: "1.0.0" };
|
||||
|
||||
// Local defaults for immediate render (should match server's DEFAULT_INPUTS)
|
||||
const FALLBACK_INPUTS: ScenarioInputs = {
|
||||
startingMRR: 50000,
|
||||
monthlyGrowthRate: 5,
|
||||
monthlyChurnRate: 3,
|
||||
grossMargin: 80,
|
||||
fixedCosts: 30000,
|
||||
};
|
||||
|
||||
function ScenarioModeler() {
|
||||
const [templates, setTemplates] = useState<ScenarioTemplate[]>([]);
|
||||
const [defaultInputs, setDefaultInputs] =
|
||||
useState<ScenarioInputs>(FALLBACK_INPUTS);
|
||||
|
||||
const { app, error } = useApp({
|
||||
appInfo: APP_INFO,
|
||||
capabilities: {},
|
||||
onAppCreated: (app) => {
|
||||
app.ontoolresult = async (result) => {
|
||||
const { templates, defaultInputs } = extractResultData(result);
|
||||
if (templates) setTemplates(templates);
|
||||
if (defaultInputs) setDefaultInputs(defaultInputs);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="main">
|
||||
<div className="loading">Error: {error.message}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<main className="main">
|
||||
<div className="loading">Connecting...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScenarioModelerInner templates={templates} defaultInputs={defaultInputs} />
|
||||
);
|
||||
}
|
||||
|
||||
interface ScenarioModelerInnerProps {
|
||||
templates: ScenarioTemplate[];
|
||||
defaultInputs: ScenarioInputs;
|
||||
}
|
||||
|
||||
function ScenarioModelerInner({
|
||||
templates,
|
||||
defaultInputs,
|
||||
}: ScenarioModelerInnerProps) {
|
||||
const [inputs, setInputs] = useState<ScenarioInputs>(FALLBACK_INPUTS);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Derived state - recalculates when inputs change
|
||||
const projections = useMemo(() => calculateProjections(inputs), [inputs]);
|
||||
const summary = useMemo(
|
||||
() => calculateSummary(projections, inputs),
|
||||
[projections, inputs],
|
||||
);
|
||||
|
||||
// Selected template (if any)
|
||||
const selectedTemplate = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplateId) ?? null,
|
||||
[templates, selectedTemplateId],
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleInputChange = useCallback(
|
||||
(key: keyof ScenarioInputs, value: number) => {
|
||||
setInputs((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setInputs(defaultInputs);
|
||||
setSelectedTemplateId(null);
|
||||
}, [defaultInputs]);
|
||||
|
||||
const handleTemplateSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setSelectedTemplateId(value || null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLoadTemplate = useCallback(() => {
|
||||
if (selectedTemplate) {
|
||||
setInputs(selectedTemplate.parameters);
|
||||
setSelectedTemplateId(null);
|
||||
}
|
||||
}, [selectedTemplate]);
|
||||
|
||||
return (
|
||||
<main className="main">
|
||||
{/* Header */}
|
||||
<header className="header">
|
||||
<h1 className="header-title">SaaS Scenario Modeler</h1>
|
||||
<div className="header-controls">
|
||||
<select
|
||||
className="template-select"
|
||||
value={selectedTemplateId ?? ""}
|
||||
onChange={handleTemplateSelect}
|
||||
>
|
||||
<option value="">Compare to...</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.icon} {t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTemplate && (
|
||||
<button className="reset-button" onClick={handleLoadTemplate}>
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
<button className="reset-button" onClick={handleReset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Parameters */}
|
||||
<section className="parameters-section">
|
||||
<h2 className="section-title">Parameters</h2>
|
||||
<SliderRow
|
||||
label="Starting MRR"
|
||||
value={inputs.startingMRR}
|
||||
min={10000}
|
||||
max={500000}
|
||||
step={5000}
|
||||
format={formatCurrencySlider}
|
||||
onChange={(v) => handleInputChange("startingMRR", v)}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Growth Rate"
|
||||
value={inputs.monthlyGrowthRate}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
format={(v) => formatPercent(v)}
|
||||
onChange={(v) => handleInputChange("monthlyGrowthRate", v)}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Churn Rate"
|
||||
value={inputs.monthlyChurnRate}
|
||||
min={0}
|
||||
max={15}
|
||||
step={0.5}
|
||||
format={(v) => formatPercent(v)}
|
||||
onChange={(v) => handleInputChange("monthlyChurnRate", v)}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Gross Margin"
|
||||
value={inputs.grossMargin}
|
||||
min={50}
|
||||
max={95}
|
||||
step={5}
|
||||
format={(v) => formatPercent(v)}
|
||||
onChange={(v) => handleInputChange("grossMargin", v)}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Fixed Costs"
|
||||
value={inputs.fixedCosts}
|
||||
min={5000}
|
||||
max={200000}
|
||||
step={5000}
|
||||
format={formatCurrencySlider}
|
||||
onChange={(v) => handleInputChange("fixedCosts", v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Chart */}
|
||||
<ProjectionChart
|
||||
userProjections={projections}
|
||||
templateProjections={selectedTemplate?.projections ?? null}
|
||||
templateName={selectedTemplate?.name}
|
||||
/>
|
||||
|
||||
{/* Metrics */}
|
||||
<MetricsSection
|
||||
userSummary={summary}
|
||||
templateSummary={selectedTemplate?.summary ?? null}
|
||||
templateName={selectedTemplate?.name}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricsSectionProps {
|
||||
userSummary: ScenarioSummary;
|
||||
templateSummary: ScenarioSummary | null;
|
||||
templateName?: string;
|
||||
}
|
||||
|
||||
function MetricsSection({
|
||||
userSummary,
|
||||
templateSummary,
|
||||
templateName,
|
||||
}: MetricsSectionProps) {
|
||||
const profitVariant = userSummary.totalProfit >= 0 ? "positive" : "negative";
|
||||
|
||||
// Calculate comparison delta
|
||||
const profitDelta = templateSummary
|
||||
? ((userSummary.totalProfit - templateSummary.totalProfit) /
|
||||
Math.abs(templateSummary.totalProfit)) *
|
||||
100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="metrics-section">
|
||||
<div className="metrics-comparison">
|
||||
{/* User's scenario */}
|
||||
<div className="metrics-column">
|
||||
<h3>Your Scenario</h3>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard
|
||||
label="End MRR"
|
||||
value={formatCurrency(userSummary.endingMRR)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Revenue"
|
||||
value={formatCurrency(userSummary.totalRevenue)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Profit"
|
||||
value={formatCurrency(userSummary.totalProfit)}
|
||||
variant={profitVariant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template comparison (only when selected) */}
|
||||
{templateSummary && templateName && (
|
||||
<div className="metrics-column template">
|
||||
<h3>vs. {templateName}</h3>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard
|
||||
label="End MRR"
|
||||
value={formatCurrency(templateSummary.endingMRR)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Profit"
|
||||
value={formatCurrency(templateSummary.totalProfit)}
|
||||
variant={
|
||||
templateSummary.totalProfit >= 0 ? "positive" : "negative"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary row */}
|
||||
<div className="metrics-summary">
|
||||
<span className="summary-item">
|
||||
Break-even:{" "}
|
||||
<span className="summary-value">
|
||||
{userSummary.breakEvenMonth
|
||||
? `Month ${userSummary.breakEvenMonth}`
|
||||
: "Not achieved"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="summary-item">
|
||||
MRR Growth:{" "}
|
||||
<span
|
||||
className={`summary-value ${userSummary.mrrGrowthPct >= 0 ? "positive" : "negative"}`}
|
||||
>
|
||||
{formatPercent(userSummary.mrrGrowthPct, true)}
|
||||
</span>
|
||||
</span>
|
||||
{profitDelta !== null && (
|
||||
<span className="summary-item">
|
||||
vs. template:{" "}
|
||||
<span
|
||||
className={`summary-value ${profitDelta >= 0 ? "positive" : "negative"}`}
|
||||
>
|
||||
{formatPercent(profitDelta, true)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ScenarioModeler />
|
||||
</StrictMode>,
|
||||
);
|
||||
36
examples/scenario-modeler-server/src/types.ts
Normal file
36
examples/scenario-modeler-server/src/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export interface ScenarioInputs {
|
||||
startingMRR: number;
|
||||
monthlyGrowthRate: number;
|
||||
monthlyChurnRate: number;
|
||||
grossMargin: number;
|
||||
fixedCosts: number;
|
||||
}
|
||||
|
||||
export interface MonthlyProjection {
|
||||
month: number; // 1-12
|
||||
mrr: number;
|
||||
grossProfit: number;
|
||||
netProfit: number;
|
||||
cumulativeRevenue: number;
|
||||
}
|
||||
|
||||
export interface ScenarioSummary {
|
||||
endingMRR: number;
|
||||
arr: number;
|
||||
totalRevenue: number;
|
||||
totalProfit: number;
|
||||
mrrGrowthPct: number;
|
||||
avgMargin: number;
|
||||
breakEvenMonth: number | null;
|
||||
}
|
||||
|
||||
export interface ScenarioTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
parameters: ScenarioInputs;
|
||||
projections: MonthlyProjection[];
|
||||
summary: ScenarioSummary;
|
||||
keyInsight: string;
|
||||
}
|
||||
20
examples/scenario-modeler-server/tsconfig.json
Normal file
20
examples/scenario-modeler-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "server.ts"]
|
||||
}
|
||||
25
examples/scenario-modeler-server/vite.config.ts
Normal file
25
examples/scenario-modeler-server/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
const INPUT = process.env.INPUT;
|
||||
if (!INPUT) {
|
||||
throw new Error("INPUT environment variable is not set");
|
||||
}
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile()],
|
||||
build: {
|
||||
sourcemap: isDevelopment ? "inline" : undefined,
|
||||
cssMinify: !isDevelopment,
|
||||
minify: !isDevelopment,
|
||||
|
||||
rollupOptions: {
|
||||
input: INPUT,
|
||||
},
|
||||
outDir: "dist",
|
||||
emptyOutDir: false,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue