Add cohort-heatmap-server example

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 <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2025-12-04 13:40:05 -06:00
commit c711b1aac4
11 changed files with 1131 additions and 7 deletions

View 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

View 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>

View 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"
}
}

View file

@ -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<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+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);

View 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;
}

View 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);
}

View 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>,
);

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "server.ts"]
}

View file

@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
if (!INPUT) {
throw new Error("INPUT environment variable is not set");
}
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: {
input: INPUT,
},
outDir: "dist",
emptyOutDir: false,
},
});

33
package-lock.json generated
View file

@ -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,