From c711b1aac490e198ba8e691af1e07c19d419b85f Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Thu, 4 Dec 2025 13:40:05 -0600 Subject: [PATCH] Add cohort-heatmap-server example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo MCP App visualizing cohort retention data with: - Interactive heatmap with HSL color interpolation - Multiple metrics: Retention %, Revenue Retention, Active Users - Monthly/weekly period views - Cell hover tooltips and row/column highlighting - Light/dark theme support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- examples/cohort-heatmap-server/README.md | 50 ++ examples/cohort-heatmap-server/mcp-app.html | 13 + examples/cohort-heatmap-server/package.json | 34 ++ examples/cohort-heatmap-server/server.ts | 256 ++++++++++ examples/cohort-heatmap-server/src/global.css | 10 + .../src/mcp-app.module.css | 238 +++++++++ .../cohort-heatmap-server/src/mcp-app.tsx | 458 ++++++++++++++++++ .../cohort-heatmap-server/src/vite-env.d.ts | 1 + examples/cohort-heatmap-server/tsconfig.json | 20 + examples/cohort-heatmap-server/vite.config.ts | 25 + package-lock.json | 33 +- 11 files changed, 1131 insertions(+), 7 deletions(-) create mode 100644 examples/cohort-heatmap-server/README.md create mode 100644 examples/cohort-heatmap-server/mcp-app.html create mode 100644 examples/cohort-heatmap-server/package.json create mode 100644 examples/cohort-heatmap-server/server.ts create mode 100644 examples/cohort-heatmap-server/src/global.css create mode 100644 examples/cohort-heatmap-server/src/mcp-app.module.css create mode 100644 examples/cohort-heatmap-server/src/mcp-app.tsx create mode 100644 examples/cohort-heatmap-server/src/vite-env.d.ts create mode 100644 examples/cohort-heatmap-server/tsconfig.json create mode 100644 examples/cohort-heatmap-server/vite.config.ts diff --git a/examples/cohort-heatmap-server/README.md b/examples/cohort-heatmap-server/README.md new file mode 100644 index 0000000..be81c06 --- /dev/null +++ b/examples/cohort-heatmap-server/README.md @@ -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 diff --git a/examples/cohort-heatmap-server/mcp-app.html b/examples/cohort-heatmap-server/mcp-app.html new file mode 100644 index 0000000..2b1a47d --- /dev/null +++ b/examples/cohort-heatmap-server/mcp-app.html @@ -0,0 +1,13 @@ + + + + + + Cohort Retention Heatmap + + + +
+ + + diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json new file mode 100644 index 0000000..6769b1c --- /dev/null +++ b/examples/cohort-heatmap-server/package.json @@ -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" + } +} diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts new file mode 100644 index 0000000..ae65b68 --- /dev/null +++ b/examples/cohort-heatmap-server/server.ts @@ -0,0 +1,256 @@ +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; +type CohortRow = z.infer; +type CohortData = z.infer; + +// 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 = { + 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 => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + + return { + contents: [{ uri: resourceUri, mimeType: "text/html+mcp", 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); diff --git a/examples/cohort-heatmap-server/src/global.css b/examples/cohort-heatmap-server/src/global.css new file mode 100644 index 0000000..b53cf5e --- /dev/null +++ b/examples/cohort-heatmap-server/src/global.css @@ -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; +} diff --git a/examples/cohort-heatmap-server/src/mcp-app.module.css b/examples/cohort-heatmap-server/src/mcp-app.module.css new file mode 100644 index 0000000..cf5dcca --- /dev/null +++ b/examples/cohort-heatmap-server/src/mcp-app.module.css @@ -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); +} diff --git a/examples/cohort-heatmap-server/src/mcp-app.tsx b/examples/cohort-heatmap-server/src/mcp-app.tsx new file mode 100644 index 0000000..e27595c --- /dev/null +++ b/examples/cohort-heatmap-server/src/mcp-app.tsx @@ -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
ERROR: {error.message}
; + if (!app) return
Connecting...
; + + return ; +} + +// Inner App with state management +function CohortHeatmapInner({ app }: { app: App }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedMetric, setSelectedMetric] = useState("retention"); + const [selectedPeriodType, setSelectedPeriodType] = + useState("monthly"); + const [highlightedCohort, setHighlightedCohort] = useState( + null, + ); + const [highlightedPeriod, setHighlightedPeriod] = useState( + null, + ); + const [tooltipData, setTooltipData] = useState(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 ( +
+
+ {loading ? ( +
Loading...
+ ) : data ? ( + + ) : ( +
Failed to load data
+ )} + + {tooltipData && } +
+ ); +} + +// 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 ( +
+

Cohort Retention Analysis

+
+ + +
+
+ ); +} + +// 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 ( +
+
+ {/* Header row: empty corner + period labels */} +
+ {data.periods.map((period, i) => ( +
+ {period} +
+ ))} + + {/* Data rows */} + {data.cohorts.map((cohort, cohortIndex) => ( + + ))} +
+
+ ); +} + +// 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 ( + <> +
+ {cohort.cohortLabel} + + {formatNumber(cohort.originalUsers)} + +
+ {Array.from({ length: periodCount }, (_, p) => { + const cellData = cohort.cells.find((c) => c.periodIndex === p); + const isCellHighlighted = isHighlighted || highlightedPeriod === p; + + if (!cellData) { + return
; + } + + return ( + 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) => { + 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 ( +
+ {Math.round(cellData.retention * 100)} +
+ ); +} + +// 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 ( +
+
+ {cohortLabel} — {periodLabel} +
+
+ Retention: + + {(retention * 100).toFixed(1)}% + +
+
+ Users: + + {formatNumber(usersRetained)} / {formatNumber(usersOriginal)} + +
+
+ ); +} + +// Legend +function Legend() { + return ( +
+ + + 80-100% + + + + 50-79% + + + + 20-49% + + + + 0-19% + +
+ ); +} + +// Entry point +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/cohort-heatmap-server/src/vite-env.d.ts b/examples/cohort-heatmap-server/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/cohort-heatmap-server/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cohort-heatmap-server/tsconfig.json b/examples/cohort-heatmap-server/tsconfig.json new file mode 100644 index 0000000..fc3c210 --- /dev/null +++ b/examples/cohort-heatmap-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/cohort-heatmap-server/vite.config.ts b/examples/cohort-heatmap-server/vite.config.ts new file mode 100644 index 0000000..da0af84 --- /dev/null +++ b/examples/cohort-heatmap-server/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/package-lock.json b/package-lock.json index a393d34..eb3ea40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,11 +109,29 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/budget-allocator-server": { - "extraneous": true - }, "examples/cohort-heatmap-server": { - "extraneous": true + "version": "1.0.0", + "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" + } }, "examples/customer-segmentation-server": { "version": "1.0.0", @@ -135,9 +153,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/scenario-modeler-server": { - "extraneous": true - }, "examples/system-monitor-server": { "version": "1.0.0", "dependencies": { @@ -1389,6 +1404,10 @@ "node": ">=12" } }, + "node_modules/cohort-heatmap-server": { + "resolved": "examples/cohort-heatmap-server", + "link": true + }, "node_modules/color-convert": { "version": "2.0.1", "dev": true,