Merge pull request #76 from jonathanhefner/marketing-examples
Add marketing examples
This commit is contained in:
commit
45259c584b
61 changed files with 6889 additions and 80 deletions
|
|
@ -132,7 +132,9 @@ server.registerResource(resourceUri, resourceUri, {}, async () => {
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }],
|
contents: [
|
||||||
|
{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,9 @@ async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<s
|
||||||
|
|
||||||
const content = resource.contents[0];
|
const content = resource.contents[0];
|
||||||
|
|
||||||
// Per the MCP App specification, "text/html+mcp" signals this resource is
|
// Per the MCP App specification, "text/html;profile=mcp-app" signals this
|
||||||
// indeed for an MCP App UI.
|
// resource is indeed for an MCP App UI.
|
||||||
if (content.mimeType !== "text/html+mcp") {
|
if (content.mimeType !== "text/html;profile=mcp-app") {
|
||||||
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
|
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ const server = new McpServer({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contents: [
|
contents: [
|
||||||
// Per the MCP App specification, "text/html+mcp" signals to the Host
|
// Per the MCP App specification, "text/html;profile=mcp-app" signals
|
||||||
// that this resource is indeed for an MCP App UI.
|
// to the Host that this resource is indeed for an MCP App UI.
|
||||||
{ uri: resourceUri, mimeType: "text/html+mcp", text: html },
|
{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ const server = new McpServer({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contents: [
|
contents: [
|
||||||
// Per the MCP App specification, "text/html+mcp" signals to the Host
|
// Per the MCP App specification, "text/html;profile=mcp-app" signals
|
||||||
// that this resource is indeed for an MCP App UI.
|
// to the Host that this resource is indeed for an MCP App UI.
|
||||||
{ uri: resourceUri, mimeType: "text/html+mcp", text: html },
|
{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
49
examples/budget-allocator-server/README.md
Normal file
49
examples/budget-allocator-server/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Example: Budget Allocator App
|
||||||
|
|
||||||
|
An interactive budget allocation tool demonstrating real-time data visualization with MCP Apps.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Interactive Sliders**: Adjust budget allocation across 5 categories (Marketing, Engineering, Operations, Sales, R&D)
|
||||||
|
- **Donut Chart**: Real-time visualization of allocation distribution using Chart.js
|
||||||
|
- **Sparkline Trends**: 24-month historical allocation data per category
|
||||||
|
- **Percentile Badges**: Compare your allocation vs. industry benchmarks
|
||||||
|
- **Stage Selector**: Switch between Seed, Series A, Series B, and Growth benchmarks
|
||||||
|
- **Budget Presets**: Quick selection of $50K, $100K, $250K, or $500K totals
|
||||||
|
|
||||||
|
## 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-budget-data` tool that returns:
|
||||||
|
|
||||||
|
- Category definitions with colors and default allocations
|
||||||
|
- Historical data (~120 data points) - 24 months of allocation history per category
|
||||||
|
- Industry benchmarks (~60 data points) - Aggregated percentile data by company stage
|
||||||
|
|
||||||
|
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
|
||||||
|
|
||||||
|
### App (`src/mcp-app.ts`)
|
||||||
|
|
||||||
|
- Uses Chart.js for the donut chart visualization
|
||||||
|
- Renders sparkline trends using inline SVG
|
||||||
|
- Computes percentile rankings client-side from benchmark data
|
||||||
|
- Updates all UI elements reactively on slider changes
|
||||||
36
examples/budget-allocator-server/mcp-app.html
Normal file
36
examples/budget-allocator-server/mcp-app.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Budget Allocator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<header class="header">
|
||||||
|
<h1 class="title">Budget Allocator</h1>
|
||||||
|
<select id="budget-selector" class="budget-select"></select>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="chart-section">
|
||||||
|
<canvas id="budget-chart"></canvas>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sliders-section" id="sliders-container"></section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div id="status-bar" class="status-bar">
|
||||||
|
Allocated: $0 / $0
|
||||||
|
</div>
|
||||||
|
<div class="comparison-bar">
|
||||||
|
<span id="comparison-summary">Loading benchmarks...</span>
|
||||||
|
<label class="stage-label">
|
||||||
|
Stage:
|
||||||
|
<select id="stage-selector" class="stage-select"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/mcp-app.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
examples/budget-allocator-server/package.json
Normal file
30
examples/budget-allocator-server/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "budget-allocator-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",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
367
examples/budget-allocator-server/server.ts
Normal file
367
examples/budget-allocator-server/server.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
/**
|
||||||
|
* Budget Allocator MCP Server
|
||||||
|
*
|
||||||
|
* Provides budget configuration, 24 months of historical allocation data,
|
||||||
|
* and industry benchmarks by company stage.
|
||||||
|
*/
|
||||||
|
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 BudgetCategorySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
defaultPercent: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const HistoricalMonthSchema = z.object({
|
||||||
|
month: z.string(),
|
||||||
|
allocations: z.record(z.string(), z.number()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BenchmarkPercentilesSchema = z.object({
|
||||||
|
p25: z.number(),
|
||||||
|
p50: z.number(),
|
||||||
|
p75: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const StageBenchmarkSchema = z.object({
|
||||||
|
stage: z.string(),
|
||||||
|
categoryBenchmarks: z.record(z.string(), BenchmarkPercentilesSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BudgetConfigSchema = z.object({
|
||||||
|
categories: z.array(BudgetCategorySchema),
|
||||||
|
presetBudgets: z.array(z.number()),
|
||||||
|
defaultBudget: z.number(),
|
||||||
|
currency: z.string(),
|
||||||
|
currencySymbol: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BudgetAnalyticsSchema = z.object({
|
||||||
|
history: z.array(HistoricalMonthSchema),
|
||||||
|
benchmarks: z.array(StageBenchmarkSchema),
|
||||||
|
stages: z.array(z.string()),
|
||||||
|
defaultStage: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BudgetDataResponseSchema = z.object({
|
||||||
|
config: BudgetConfigSchema,
|
||||||
|
analytics: BudgetAnalyticsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Types derived from schemas
|
||||||
|
type BudgetDataResponse = z.infer<typeof BudgetDataResponseSchema>;
|
||||||
|
type HistoricalMonth = z.infer<typeof HistoricalMonthSchema>;
|
||||||
|
type StageBenchmark = z.infer<typeof StageBenchmarkSchema>;
|
||||||
|
|
||||||
|
// Internal type (not part of API schema - includes trendPerMonth for data generation)
|
||||||
|
type BudgetCategoryInternal = z.infer<typeof BudgetCategorySchema> & {
|
||||||
|
trendPerMonth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Budget Categories with Trend Data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CATEGORIES: BudgetCategoryInternal[] = [
|
||||||
|
{
|
||||||
|
id: "marketing",
|
||||||
|
name: "Marketing",
|
||||||
|
color: "#3b82f6",
|
||||||
|
defaultPercent: 25,
|
||||||
|
trendPerMonth: 0.15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "engineering",
|
||||||
|
name: "Engineering",
|
||||||
|
color: "#10b981",
|
||||||
|
defaultPercent: 35,
|
||||||
|
trendPerMonth: -0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "operations",
|
||||||
|
name: "Operations",
|
||||||
|
color: "#f59e0b",
|
||||||
|
defaultPercent: 15,
|
||||||
|
trendPerMonth: 0.05,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sales",
|
||||||
|
name: "Sales",
|
||||||
|
color: "#ef4444",
|
||||||
|
defaultPercent: 15,
|
||||||
|
trendPerMonth: 0.08,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rd",
|
||||||
|
name: "R&D",
|
||||||
|
color: "#8b5cf6",
|
||||||
|
defaultPercent: 10,
|
||||||
|
trendPerMonth: -0.18,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Industry Benchmarks by Company Stage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BENCHMARKS: StageBenchmark[] = [
|
||||||
|
{
|
||||||
|
stage: "Seed",
|
||||||
|
categoryBenchmarks: {
|
||||||
|
marketing: { p25: 15, p50: 20, p75: 25 },
|
||||||
|
engineering: { p25: 40, p50: 47, p75: 55 },
|
||||||
|
operations: { p25: 8, p50: 12, p75: 15 },
|
||||||
|
sales: { p25: 10, p50: 15, p75: 20 },
|
||||||
|
rd: { p25: 5, p50: 10, p75: 15 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "Series A",
|
||||||
|
categoryBenchmarks: {
|
||||||
|
marketing: { p25: 20, p50: 25, p75: 30 },
|
||||||
|
engineering: { p25: 35, p50: 40, p75: 45 },
|
||||||
|
operations: { p25: 10, p50: 14, p75: 18 },
|
||||||
|
sales: { p25: 15, p50: 20, p75: 25 },
|
||||||
|
rd: { p25: 8, p50: 12, p75: 15 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "Series B",
|
||||||
|
categoryBenchmarks: {
|
||||||
|
marketing: { p25: 22, p50: 27, p75: 32 },
|
||||||
|
engineering: { p25: 30, p50: 35, p75: 40 },
|
||||||
|
operations: { p25: 12, p50: 16, p75: 20 },
|
||||||
|
sales: { p25: 18, p50: 23, p75: 28 },
|
||||||
|
rd: { p25: 8, p50: 12, p75: 15 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "Growth",
|
||||||
|
categoryBenchmarks: {
|
||||||
|
marketing: { p25: 25, p50: 30, p75: 35 },
|
||||||
|
engineering: { p25: 25, p50: 30, p75: 35 },
|
||||||
|
operations: { p25: 15, p50: 18, p75: 22 },
|
||||||
|
sales: { p25: 20, p50: 25, p75: 30 },
|
||||||
|
rd: { p25: 5, p50: 8, p75: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Historical Data Generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeded random number generator for reproducible historical data
|
||||||
|
*/
|
||||||
|
function seededRandom(seed: number): () => number {
|
||||||
|
return () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return seed / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate 24 months of historical allocation data with realistic trends
|
||||||
|
*/
|
||||||
|
function generateHistory(
|
||||||
|
categories: BudgetCategoryInternal[],
|
||||||
|
): HistoricalMonth[] {
|
||||||
|
const months: HistoricalMonth[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
const random = seededRandom(42); // Fixed seed for reproducibility
|
||||||
|
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setMonth(date.getMonth() - i);
|
||||||
|
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const rawAllocations: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
// Start from default, apply trend over time, add noise
|
||||||
|
const monthsFromStart = 23 - i;
|
||||||
|
const trend = monthsFromStart * cat.trendPerMonth;
|
||||||
|
const noise = (random() - 0.5) * 3; // +/- 1.5%
|
||||||
|
rawAllocations[cat.id] = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, cat.defaultPercent + trend + noise),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to 100%
|
||||||
|
const total = Object.values(rawAllocations).reduce((a, b) => a + b, 0);
|
||||||
|
const allocations: Record<string, number> = {};
|
||||||
|
for (const id of Object.keys(rawAllocations)) {
|
||||||
|
allocations[id] = Math.round((rawAllocations[id] / total) * 1000) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
months.push({ month: monthStr, allocations });
|
||||||
|
}
|
||||||
|
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response Formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatBudgetSummary(data: BudgetDataResponse): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
"Budget Allocator Configuration",
|
||||||
|
"==============================",
|
||||||
|
"",
|
||||||
|
`Default Budget: ${data.config.currencySymbol}${data.config.defaultBudget.toLocaleString()}`,
|
||||||
|
`Available Presets: ${data.config.presetBudgets.map((b) => `${data.config.currencySymbol}${b.toLocaleString()}`).join(", ")}`,
|
||||||
|
"",
|
||||||
|
"Categories:",
|
||||||
|
...data.config.categories.map(
|
||||||
|
(c) => ` - ${c.name}: ${c.defaultPercent}% default`,
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
`Historical Data: ${data.analytics.history.length} months`,
|
||||||
|
`Benchmark Stages: ${data.analytics.stages.join(", ")}`,
|
||||||
|
`Default Stage: ${data.analytics.defaultStage}`,
|
||||||
|
];
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MCP Server Setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "Budget Allocator Server",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceUri = "ui://budget-allocator/mcp-app.html";
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get-budget-data",
|
||||||
|
{
|
||||||
|
title: "Get Budget Data",
|
||||||
|
description:
|
||||||
|
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
|
||||||
|
inputSchema: {},
|
||||||
|
outputSchema: BudgetDataResponseSchema.shape,
|
||||||
|
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
|
||||||
|
},
|
||||||
|
async (): Promise<CallToolResult> => {
|
||||||
|
const response: BudgetDataResponse = {
|
||||||
|
config: {
|
||||||
|
categories: CATEGORIES.map(({ id, name, color, defaultPercent }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
defaultPercent,
|
||||||
|
})),
|
||||||
|
presetBudgets: [50000, 100000, 250000, 500000],
|
||||||
|
defaultBudget: 100000,
|
||||||
|
currency: "USD",
|
||||||
|
currencySymbol: "$",
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
history: generateHistory(CATEGORIES),
|
||||||
|
benchmarks: BENCHMARKS,
|
||||||
|
stages: ["Seed", "Series A", "Series B", "Growth"],
|
||||||
|
defaultStage: "Series A",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: formatBudgetSummary(response),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: response,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerResource(
|
||||||
|
resourceUri,
|
||||||
|
resourceUri,
|
||||||
|
{ description: "Interactive Budget Allocator 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;profile=mcp-app", 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(
|
||||||
|
`Budget Allocator 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);
|
||||||
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));
|
||||||
19
examples/budget-allocator-server/tsconfig.json
Normal file
19
examples/budget-allocator-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src", "server.ts"]
|
||||||
|
}
|
||||||
24
examples/budget-allocator-server/vite.config.ts
Normal file
24
examples/budget-allocator-server/vite.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
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: [viteSingleFile()],
|
||||||
|
build: {
|
||||||
|
sourcemap: isDevelopment ? "inline" : undefined,
|
||||||
|
cssMinify: !isDevelopment,
|
||||||
|
minify: !isDevelopment,
|
||||||
|
|
||||||
|
rollupOptions: {
|
||||||
|
input: INPUT,
|
||||||
|
},
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
50
examples/cohort-heatmap-server/README.md
Normal file
50
examples/cohort-heatmap-server/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Example: Cohort Heatmap App
|
||||||
|
|
||||||
|
A demo MCP App that displays cohort retention data as an interactive heatmap, showing customer retention over time by signup month.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Cohort Retention Heatmap**: Color-coded grid showing retention percentages across cohorts and time periods
|
||||||
|
- **Multiple Metrics**: Switch between Retention %, Revenue Retention, and Active Users
|
||||||
|
- **Period Types**: View data by monthly or weekly intervals
|
||||||
|
- **Interactive Exploration**: Hover cells for detailed tooltips, click to highlight rows/columns
|
||||||
|
- **Color Scale**: Green (high retention) through yellow to red (low retention)
|
||||||
|
- **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-cohort-data` tool that returns:
|
||||||
|
|
||||||
|
- Cohort rows with signup month, original user count, and retention cells
|
||||||
|
- Period headers and labels
|
||||||
|
- Configurable parameters: metric type, period type, cohort count, max periods
|
||||||
|
|
||||||
|
The tool generates synthetic cohort data using an exponential decay model with configurable retention curves per metric type.
|
||||||
|
|
||||||
|
### App (`src/mcp-app.tsx`)
|
||||||
|
|
||||||
|
- Uses React for the heatmap visualization
|
||||||
|
- Fetches data on mount and when filters change
|
||||||
|
- Displays retention percentages in a grid with HSL color interpolation
|
||||||
|
- Shows detailed tooltips on hover with user counts and exact retention values
|
||||||
|
- Supports row/column highlighting on cell click
|
||||||
13
examples/cohort-heatmap-server/mcp-app.html
Normal file
13
examples/cohort-heatmap-server/mcp-app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cohort Retention Heatmap</title>
|
||||||
|
<link rel="stylesheet" href="/src/global.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/mcp-app.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
examples/cohort-heatmap-server/package.json
Normal file
34
examples/cohort-heatmap-server/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "cohort-heatmap-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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
262
examples/cohort-heatmap-server/server.ts
Normal file
262
examples/cohort-heatmap-server/server.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
|
import type { 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 GetCohortDataInputSchema = z.object({
|
||||||
|
metric: z
|
||||||
|
.enum(["retention", "revenue", "active"])
|
||||||
|
.optional()
|
||||||
|
.default("retention"),
|
||||||
|
periodType: z.enum(["monthly", "weekly"]).optional().default("monthly"),
|
||||||
|
cohortCount: z.number().min(3).max(24).optional().default(12),
|
||||||
|
maxPeriods: z.number().min(3).max(24).optional().default(12),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CohortCellSchema = z.object({
|
||||||
|
cohortIndex: z.number(),
|
||||||
|
periodIndex: z.number(),
|
||||||
|
retention: z.number(),
|
||||||
|
usersRetained: z.number(),
|
||||||
|
usersOriginal: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CohortRowSchema = z.object({
|
||||||
|
cohortId: z.string(),
|
||||||
|
cohortLabel: z.string(),
|
||||||
|
originalUsers: z.number(),
|
||||||
|
cells: z.array(CohortCellSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CohortDataSchema = z.object({
|
||||||
|
cohorts: z.array(CohortRowSchema),
|
||||||
|
periods: z.array(z.string()),
|
||||||
|
periodLabels: z.array(z.string()),
|
||||||
|
metric: z.string(),
|
||||||
|
periodType: z.string(),
|
||||||
|
generatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Types derived from schemas
|
||||||
|
type CohortCell = z.infer<typeof CohortCellSchema>;
|
||||||
|
type CohortRow = z.infer<typeof CohortRowSchema>;
|
||||||
|
type CohortData = z.infer<typeof CohortDataSchema>;
|
||||||
|
|
||||||
|
// Internal types (not part of API schema)
|
||||||
|
interface RetentionParams {
|
||||||
|
baseRetention: number;
|
||||||
|
decayRate: number;
|
||||||
|
floor: number;
|
||||||
|
noise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retention curve generator using exponential decay
|
||||||
|
function generateRetention(period: number, params: RetentionParams): number {
|
||||||
|
if (period === 0) return 1.0;
|
||||||
|
|
||||||
|
const { baseRetention, decayRate, floor, noise } = params;
|
||||||
|
const base = baseRetention * Math.exp(-decayRate * (period - 1)) + floor;
|
||||||
|
const variation = (Math.random() - 0.5) * 2 * noise;
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(1, base + variation));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate cohort data
|
||||||
|
function generateCohortData(
|
||||||
|
metric: string,
|
||||||
|
periodType: string,
|
||||||
|
cohortCount: number,
|
||||||
|
maxPeriods: number,
|
||||||
|
): CohortData {
|
||||||
|
const now = new Date();
|
||||||
|
const cohorts: CohortRow[] = [];
|
||||||
|
const periods: string[] = [];
|
||||||
|
const periodLabels: string[] = [];
|
||||||
|
|
||||||
|
// Generate period headers
|
||||||
|
for (let i = 0; i < maxPeriods; i++) {
|
||||||
|
periods.push(`M${i}`);
|
||||||
|
periodLabels.push(i === 0 ? "Month 0" : `Month ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retention parameters vary by metric type
|
||||||
|
const paramsMap: Record<string, RetentionParams> = {
|
||||||
|
retention: {
|
||||||
|
baseRetention: 0.75,
|
||||||
|
decayRate: 0.12,
|
||||||
|
floor: 0.08,
|
||||||
|
noise: 0.04,
|
||||||
|
},
|
||||||
|
revenue: { baseRetention: 0.7, decayRate: 0.1, floor: 0.15, noise: 0.06 },
|
||||||
|
active: { baseRetention: 0.6, decayRate: 0.18, floor: 0.05, noise: 0.05 },
|
||||||
|
};
|
||||||
|
const params = paramsMap[metric] ?? paramsMap.retention;
|
||||||
|
|
||||||
|
// Generate cohorts (oldest first)
|
||||||
|
for (let c = 0; c < cohortCount; c++) {
|
||||||
|
const cohortDate = new Date(now);
|
||||||
|
cohortDate.setMonth(cohortDate.getMonth() - (cohortCount - 1 - c));
|
||||||
|
|
||||||
|
const cohortId = `${cohortDate.getFullYear()}-${String(cohortDate.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const cohortLabel = cohortDate.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Random cohort size: 1000-5000 users
|
||||||
|
const originalUsers = Math.floor(1000 + Math.random() * 4000);
|
||||||
|
|
||||||
|
// Number of periods this cohort has data for (newer cohorts have fewer periods)
|
||||||
|
const periodsAvailable = cohortCount - c;
|
||||||
|
|
||||||
|
const cells: CohortCell[] = [];
|
||||||
|
let previousRetention = 1.0;
|
||||||
|
|
||||||
|
for (let p = 0; p < Math.min(periodsAvailable, maxPeriods); p++) {
|
||||||
|
// Retention must decrease or stay same (with small exceptions for noise)
|
||||||
|
let retention = generateRetention(p, params);
|
||||||
|
retention = Math.min(retention, previousRetention + 0.02);
|
||||||
|
previousRetention = retention;
|
||||||
|
|
||||||
|
cells.push({
|
||||||
|
cohortIndex: c,
|
||||||
|
periodIndex: p,
|
||||||
|
retention,
|
||||||
|
usersRetained: Math.round(originalUsers * retention),
|
||||||
|
usersOriginal: originalUsers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cohorts.push({ cohortId, cohortLabel, originalUsers, cells });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cohorts,
|
||||||
|
periods,
|
||||||
|
periodLabels,
|
||||||
|
metric,
|
||||||
|
periodType,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCohortSummary(data: CohortData): string {
|
||||||
|
const avgRetention = data.cohorts
|
||||||
|
.flatMap((c) => c.cells)
|
||||||
|
.filter((cell) => cell.periodIndex > 0)
|
||||||
|
.reduce((sum, cell, _, arr) => sum + cell.retention / arr.length, 0);
|
||||||
|
|
||||||
|
return `Cohort Analysis: ${data.cohorts.length} cohorts, ${data.periods.length} periods
|
||||||
|
Average retention: ${(avgRetention * 100).toFixed(1)}%
|
||||||
|
Metric: ${data.metric}, Period: ${data.periodType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "Cohort Heatmap Server",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register tool and resource
|
||||||
|
{
|
||||||
|
const resourceUri = "ui://get-cohort-data/mcp-app.html";
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get-cohort-data",
|
||||||
|
{
|
||||||
|
title: "Get Cohort Retention Data",
|
||||||
|
description:
|
||||||
|
"Returns cohort retention heatmap data showing customer retention over time by signup month",
|
||||||
|
inputSchema: GetCohortDataInputSchema.shape,
|
||||||
|
outputSchema: CohortDataSchema.shape,
|
||||||
|
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
|
||||||
|
},
|
||||||
|
async ({ metric, periodType, cohortCount, maxPeriods }) => {
|
||||||
|
const data = generateCohortData(
|
||||||
|
metric,
|
||||||
|
periodType,
|
||||||
|
cohortCount,
|
||||||
|
maxPeriods,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatCohortSummary(data) }],
|
||||||
|
structuredContent: data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerResource(
|
||||||
|
resourceUri,
|
||||||
|
resourceUri,
|
||||||
|
{},
|
||||||
|
async (): Promise<ReadResourceResult> => {
|
||||||
|
const html = await fs.readFile(
|
||||||
|
path.join(DIST_DIR, "mcp-app.html"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
uri: resourceUri,
|
||||||
|
mimeType: "text/html;profile=mcp-app",
|
||||||
|
text: html,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`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);
|
||||||
10
examples/cohort-heatmap-server/src/global.css
Normal file
10
examples/cohort-heatmap-server/src/global.css
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
238
examples/cohort-heatmap-server/src/mcp-app.module.css
Normal file
238
examples/cohort-heatmap-server/src/mcp-app.module.css
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
/* CSS Variables for theming */
|
||||||
|
.container {
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-text: #1f2937;
|
||||||
|
--color-text-muted: #6b7280;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
|
||||||
|
/* Retention colors */
|
||||||
|
--color-retention-high: #22c55e;
|
||||||
|
--color-retention-medium: #eab308;
|
||||||
|
--color-retention-low: #f97316;
|
||||||
|
--color-retention-critical: #ef4444;
|
||||||
|
|
||||||
|
--color-highlight: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.container {
|
||||||
|
--color-bg: #111827;
|
||||||
|
--color-text: #f9fafb;
|
||||||
|
--color-text-muted: #9ca3af;
|
||||||
|
--color-border: #374151;
|
||||||
|
--color-highlight: rgba(96, 165, 250, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 600px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heatmap Grid */
|
||||||
|
.heatmapWrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmapGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCorner {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerPeriod {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerPeriod.highlighted {
|
||||||
|
background: var(--color-highlight);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row Labels */
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding-right: 8px;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label.highlighted {
|
||||||
|
background: var(--color-highlight);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cohortName {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cohortSize {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data Cells */
|
||||||
|
.cell {
|
||||||
|
width: 44px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.highlighted {
|
||||||
|
outline: 2px solid var(--color-text);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellEmpty {
|
||||||
|
width: 44px;
|
||||||
|
height: 32px;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.legend {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendColor {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
.tooltip {
|
||||||
|
position: fixed;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipHeader {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipLabel {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipValue {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading/Error states */
|
||||||
|
.loading, .error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-retention-critical);
|
||||||
|
}
|
||||||
458
examples/cohort-heatmap-server/src/mcp-app.tsx
Normal file
458
examples/cohort-heatmap-server/src/mcp-app.tsx
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
/**
|
||||||
|
* Cohort Retention Heatmap - MCP App
|
||||||
|
*
|
||||||
|
* Interactive cohort retention analysis heatmap showing customer retention
|
||||||
|
* over time by signup month. Hover for details, click to drill down.
|
||||||
|
*/
|
||||||
|
import type { App } from "@modelcontextprotocol/ext-apps";
|
||||||
|
import { useApp } from "@modelcontextprotocol/ext-apps/react";
|
||||||
|
import { StrictMode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import styles from "./mcp-app.module.css";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface CohortCell {
|
||||||
|
cohortIndex: number;
|
||||||
|
periodIndex: number;
|
||||||
|
retention: number;
|
||||||
|
usersRetained: number;
|
||||||
|
usersOriginal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CohortRow {
|
||||||
|
cohortId: string;
|
||||||
|
cohortLabel: string;
|
||||||
|
originalUsers: number;
|
||||||
|
cells: CohortCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CohortData {
|
||||||
|
cohorts: CohortRow[];
|
||||||
|
periods: string[];
|
||||||
|
periodLabels: string[];
|
||||||
|
metric: string;
|
||||||
|
periodType: string;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipData {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
cohortLabel: string;
|
||||||
|
periodLabel: string;
|
||||||
|
retention: number;
|
||||||
|
usersRetained: number;
|
||||||
|
usersOriginal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricType = "retention" | "revenue" | "active";
|
||||||
|
type PeriodType = "monthly" | "weekly";
|
||||||
|
|
||||||
|
const IMPLEMENTATION = { name: "Cohort Heatmap", version: "1.0.0" };
|
||||||
|
|
||||||
|
// Color scale function: Green (high) -> Yellow (medium) -> Red (low)
|
||||||
|
function getRetentionColor(retention: number): string {
|
||||||
|
const hue = retention * 120; // 0-120 range (red to green)
|
||||||
|
const saturation = 70;
|
||||||
|
const lightness = 45 + (1 - retention) * 15;
|
||||||
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format number with commas
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main App Component
|
||||||
|
function CohortHeatmapApp() {
|
||||||
|
const { app, error } = useApp({
|
||||||
|
appInfo: IMPLEMENTATION,
|
||||||
|
capabilities: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return <div className={styles.error}>ERROR: {error.message}</div>;
|
||||||
|
if (!app) return <div className={styles.loading}>Connecting...</div>;
|
||||||
|
|
||||||
|
return <CohortHeatmapInner app={app} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner App with state management
|
||||||
|
function CohortHeatmapInner({ app }: { app: App }) {
|
||||||
|
const [data, setData] = useState<CohortData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedMetric, setSelectedMetric] = useState<MetricType>("retention");
|
||||||
|
const [selectedPeriodType, setSelectedPeriodType] =
|
||||||
|
useState<PeriodType>("monthly");
|
||||||
|
const [highlightedCohort, setHighlightedCohort] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [highlightedPeriod, setHighlightedPeriod] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "get-cohort-data",
|
||||||
|
arguments: {
|
||||||
|
metric: selectedMetric,
|
||||||
|
periodType: selectedPeriodType,
|
||||||
|
cohortCount: 12,
|
||||||
|
maxPeriods: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setData(result.structuredContent as unknown as CohortData);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch cohort data:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [app, selectedMetric, selectedPeriodType]);
|
||||||
|
|
||||||
|
// Fetch data on mount and when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleCellClick = useCallback(
|
||||||
|
(cohortIndex: number, periodIndex: number) => {
|
||||||
|
setHighlightedCohort(cohortIndex);
|
||||||
|
setHighlightedPeriod(periodIndex);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMetricChange = useCallback((metric: MetricType) => {
|
||||||
|
setSelectedMetric(metric);
|
||||||
|
setHighlightedCohort(null);
|
||||||
|
setHighlightedPeriod(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePeriodTypeChange = useCallback((periodType: PeriodType) => {
|
||||||
|
setSelectedPeriodType(periodType);
|
||||||
|
setHighlightedCohort(null);
|
||||||
|
setHighlightedPeriod(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.container}>
|
||||||
|
<Header
|
||||||
|
selectedMetric={selectedMetric}
|
||||||
|
selectedPeriodType={selectedPeriodType}
|
||||||
|
onMetricChange={handleMetricChange}
|
||||||
|
onPeriodTypeChange={handlePeriodTypeChange}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>Loading...</div>
|
||||||
|
) : data ? (
|
||||||
|
<HeatmapGrid
|
||||||
|
data={data}
|
||||||
|
highlightedCohort={highlightedCohort}
|
||||||
|
highlightedPeriod={highlightedPeriod}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
onCellHover={setTooltipData}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.error}>Failed to load data</div>
|
||||||
|
)}
|
||||||
|
<Legend />
|
||||||
|
{tooltipData && <Tooltip {...tooltipData} />}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header with controls
|
||||||
|
interface HeaderProps {
|
||||||
|
selectedMetric: MetricType;
|
||||||
|
selectedPeriodType: PeriodType;
|
||||||
|
onMetricChange: (metric: MetricType) => void;
|
||||||
|
onPeriodTypeChange: (periodType: PeriodType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({
|
||||||
|
selectedMetric,
|
||||||
|
selectedPeriodType,
|
||||||
|
onMetricChange,
|
||||||
|
onPeriodTypeChange,
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1 className={styles.title}>Cohort Retention Analysis</h1>
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<label className={styles.control}>
|
||||||
|
<span>Metric:</span>
|
||||||
|
<select
|
||||||
|
value={selectedMetric}
|
||||||
|
onChange={(e) => onMetricChange(e.target.value as MetricType)}
|
||||||
|
>
|
||||||
|
<option value="retention">Retention %</option>
|
||||||
|
<option value="revenue">Revenue Retention</option>
|
||||||
|
<option value="active">Active Users</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.control}>
|
||||||
|
<span>Period:</span>
|
||||||
|
<select
|
||||||
|
value={selectedPeriodType}
|
||||||
|
onChange={(e) => onPeriodTypeChange(e.target.value as PeriodType)}
|
||||||
|
>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap Grid
|
||||||
|
interface HeatmapGridProps {
|
||||||
|
data: CohortData;
|
||||||
|
highlightedCohort: number | null;
|
||||||
|
highlightedPeriod: number | null;
|
||||||
|
onCellClick: (cohortIndex: number, periodIndex: number) => void;
|
||||||
|
onCellHover: (tooltip: TooltipData | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeatmapGrid({
|
||||||
|
data,
|
||||||
|
highlightedCohort,
|
||||||
|
highlightedPeriod,
|
||||||
|
onCellClick,
|
||||||
|
onCellHover,
|
||||||
|
}: HeatmapGridProps) {
|
||||||
|
const gridStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
gridTemplateColumns: `120px repeat(${data.periods.length}, 44px)`,
|
||||||
|
}),
|
||||||
|
[data.periods.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.heatmapWrapper}>
|
||||||
|
<div className={styles.heatmapGrid} style={gridStyle}>
|
||||||
|
{/* Header row: empty corner + period labels */}
|
||||||
|
<div className={styles.headerCorner} />
|
||||||
|
{data.periods.map((period, i) => (
|
||||||
|
<div
|
||||||
|
key={period}
|
||||||
|
className={`${styles.headerPeriod} ${highlightedPeriod === i ? styles.highlighted : ""}`}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Data rows */}
|
||||||
|
{data.cohorts.map((cohort, cohortIndex) => (
|
||||||
|
<CohortRowComponent
|
||||||
|
key={cohort.cohortId}
|
||||||
|
cohort={cohort}
|
||||||
|
cohortIndex={cohortIndex}
|
||||||
|
periodCount={data.periods.length}
|
||||||
|
periodLabels={data.periodLabels}
|
||||||
|
isHighlighted={highlightedCohort === cohortIndex}
|
||||||
|
highlightedPeriod={highlightedPeriod}
|
||||||
|
onCellClick={onCellClick}
|
||||||
|
onCellHover={onCellHover}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cohort Row
|
||||||
|
interface CohortRowProps {
|
||||||
|
cohort: CohortRow;
|
||||||
|
cohortIndex: number;
|
||||||
|
periodCount: number;
|
||||||
|
periodLabels: string[];
|
||||||
|
isHighlighted: boolean;
|
||||||
|
highlightedPeriod: number | null;
|
||||||
|
onCellClick: (cohortIndex: number, periodIndex: number) => void;
|
||||||
|
onCellHover: (tooltip: TooltipData | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CohortRowComponent({
|
||||||
|
cohort,
|
||||||
|
cohortIndex,
|
||||||
|
periodCount,
|
||||||
|
periodLabels,
|
||||||
|
isHighlighted,
|
||||||
|
highlightedPeriod,
|
||||||
|
onCellClick,
|
||||||
|
onCellHover,
|
||||||
|
}: CohortRowProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${styles.label} ${isHighlighted ? styles.highlighted : ""}`}
|
||||||
|
>
|
||||||
|
<span className={styles.cohortName}>{cohort.cohortLabel}</span>
|
||||||
|
<span className={styles.cohortSize}>
|
||||||
|
{formatNumber(cohort.originalUsers)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: periodCount }, (_, p) => {
|
||||||
|
const cellData = cohort.cells.find((c) => c.periodIndex === p);
|
||||||
|
const isCellHighlighted = isHighlighted || highlightedPeriod === p;
|
||||||
|
|
||||||
|
if (!cellData) {
|
||||||
|
return <div key={p} className={styles.cellEmpty} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeatmapCell
|
||||||
|
key={p}
|
||||||
|
cellData={cellData}
|
||||||
|
cohort={cohort}
|
||||||
|
periodLabel={periodLabels[p]}
|
||||||
|
isHighlighted={isCellHighlighted}
|
||||||
|
onClick={() => onCellClick(cohortIndex, p)}
|
||||||
|
onHover={onCellHover}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap Cell
|
||||||
|
interface HeatmapCellProps {
|
||||||
|
cellData: CohortCell;
|
||||||
|
cohort: CohortRow;
|
||||||
|
periodLabel: string;
|
||||||
|
isHighlighted: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onHover: (tooltip: TooltipData | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeatmapCell({
|
||||||
|
cellData,
|
||||||
|
cohort,
|
||||||
|
periodLabel,
|
||||||
|
isHighlighted,
|
||||||
|
onClick,
|
||||||
|
onHover,
|
||||||
|
}: HeatmapCellProps) {
|
||||||
|
const backgroundColor = useMemo(
|
||||||
|
() => getRetentionColor(cellData.retention),
|
||||||
|
[cellData.retention],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
onHover({
|
||||||
|
x: rect.right + 8,
|
||||||
|
y: rect.top,
|
||||||
|
cohortLabel: cohort.cohortLabel,
|
||||||
|
periodLabel,
|
||||||
|
retention: cellData.retention,
|
||||||
|
usersRetained: cellData.usersRetained,
|
||||||
|
usersOriginal: cellData.usersOriginal,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[cellData, cohort.cohortLabel, periodLabel, onHover],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
onHover(null);
|
||||||
|
}, [onHover]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.cell} ${isHighlighted ? styles.highlighted : ""}`}
|
||||||
|
style={{ backgroundColor }}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{Math.round(cellData.retention * 100)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
function Tooltip({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
cohortLabel,
|
||||||
|
periodLabel,
|
||||||
|
retention,
|
||||||
|
usersRetained,
|
||||||
|
usersOriginal,
|
||||||
|
}: TooltipData) {
|
||||||
|
const style = useMemo(() => {
|
||||||
|
let left = x;
|
||||||
|
if (left + 200 > window.innerWidth) {
|
||||||
|
left = x - 216;
|
||||||
|
}
|
||||||
|
return { left, top: y };
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip} style={style}>
|
||||||
|
<div className={styles.tooltipHeader}>
|
||||||
|
{cohortLabel} — {periodLabel}
|
||||||
|
</div>
|
||||||
|
<div className={styles.tooltipRow}>
|
||||||
|
<span className={styles.tooltipLabel}>Retention:</span>
|
||||||
|
<span className={styles.tooltipValue}>
|
||||||
|
{(retention * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tooltipRow}>
|
||||||
|
<span className={styles.tooltipLabel}>Users:</span>
|
||||||
|
<span className={styles.tooltipValue}>
|
||||||
|
{formatNumber(usersRetained)} / {formatNumber(usersOriginal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
function Legend() {
|
||||||
|
return (
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.legendColor}
|
||||||
|
style={{ backgroundColor: getRetentionColor(0.9) }}
|
||||||
|
/>
|
||||||
|
80-100%
|
||||||
|
</span>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.legendColor}
|
||||||
|
style={{ backgroundColor: getRetentionColor(0.65) }}
|
||||||
|
/>
|
||||||
|
50-79%
|
||||||
|
</span>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.legendColor}
|
||||||
|
style={{ backgroundColor: getRetentionColor(0.35) }}
|
||||||
|
/>
|
||||||
|
20-49%
|
||||||
|
</span>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.legendColor}
|
||||||
|
style={{ backgroundColor: getRetentionColor(0.1) }}
|
||||||
|
/>
|
||||||
|
0-19%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry point
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<CohortHeatmapApp />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue