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:
parent
98d8fcddff
commit
c711b1aac4
11 changed files with 1131 additions and 7 deletions
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"
|
||||
}
|
||||
}
|
||||
256
examples/cohort-heatmap-server/server.ts
Normal file
256
examples/cohort-heatmap-server/server.ts
Normal 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);
|
||||
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>,
|
||||
);
|
||||
1
examples/cohort-heatmap-server/src/vite-env.d.ts
vendored
Normal file
1
examples/cohort-heatmap-server/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
20
examples/cohort-heatmap-server/tsconfig.json
Normal file
20
examples/cohort-heatmap-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "server.ts"]
|
||||
}
|
||||
25
examples/cohort-heatmap-server/vite.config.ts
Normal file
25
examples/cohort-heatmap-server/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
const INPUT = process.env.INPUT;
|
||||
if (!INPUT) {
|
||||
throw new Error("INPUT environment variable is not set");
|
||||
}
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile()],
|
||||
build: {
|
||||
sourcemap: isDevelopment ? "inline" : undefined,
|
||||
cssMinify: !isDevelopment,
|
||||
minify: !isDevelopment,
|
||||
|
||||
rollupOptions: {
|
||||
input: INPUT,
|
||||
},
|
||||
outDir: "dist",
|
||||
emptyOutDir: false,
|
||||
},
|
||||
});
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue