/**
* 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, McpUiHostContext } 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 [hostContext, setHostContext] = useState<
McpUiHostContext | undefined
>();
const { app, error } = useApp({
appInfo: IMPLEMENTATION,
capabilities: {},
onAppCreated: (app) => {
app.onhostcontextchanged = (params) => {
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
useEffect(() => {
if (app) {
setHostContext(app.getHostContext());
}
}, [app]);
if (error) return
ERROR: {error.message}
;
if (!app) return Connecting...
;
return ;
}
// Inner App with state management
function CohortHeatmapInner({
app,
hostContext,
}: {
app: App;
hostContext?: McpUiHostContext;
}) {
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 (
);
}
// 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(
,
);