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
4 changed files with 1131 additions and 7 deletions

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" />