/** * 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, }, }); const text = result .content!.filter( (c): c is { type: "text"; text: string } => c.type === "text", ) .map((c) => c.text) .join(""); setData(JSON.parse(text) 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( , );