413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
"use client";
|
|
/**
|
|
* CellMatrix — 38-feature x 17-integration grid.
|
|
*
|
|
* Rows grouped by FeatureCategory with collapsible category separators.
|
|
* Integration columns auto-sorted by parity tier (reference first,
|
|
* then at_parity, partial, minimal, not_wired), alphabetical within tier.
|
|
* Each cell renders a DepthChip.
|
|
*
|
|
* Uses a single flat <table> — category headers and feature rows are
|
|
* sibling <tr> elements sharing the same column structure.
|
|
*/
|
|
import { useMemo, useState, useCallback, useRef, useEffect } from "react";
|
|
import { DepthChip } from "./depth-chip";
|
|
import { CellDrilldown } from "./cell-drilldown";
|
|
import { IntegrationHeader } from "./integration-header";
|
|
import { useCollapsible, CategoryHeaderRow } from "./collapsible-category";
|
|
import { buildCellModel } from "@/lib/cell-model";
|
|
import type { CatalogCell } from "./depth-utils";
|
|
import type { ParityTier } from "./parity-badge";
|
|
import type { FilterMode } from "./filter-chips";
|
|
import { resolveCell } from "@/lib/live-status";
|
|
import type { LiveStatusMap, ConnectionStatus } from "@/lib/live-status";
|
|
import type { FeatureCategory } from "@/lib/registry";
|
|
|
|
/** Identifies a selected cell for drilldown. */
|
|
export interface SelectedCell {
|
|
slug: string;
|
|
featureId: string;
|
|
integrationName: string;
|
|
featureName: string;
|
|
}
|
|
|
|
export interface IntegrationInfo {
|
|
slug: string;
|
|
name: string;
|
|
tier: ParityTier;
|
|
}
|
|
|
|
export interface FeatureInfo {
|
|
id: string;
|
|
name: string;
|
|
category: string;
|
|
}
|
|
|
|
export interface CellMatrixProps {
|
|
cells: CatalogCell[];
|
|
categories: FeatureCategory[];
|
|
features: FeatureInfo[];
|
|
integrations: IntegrationInfo[];
|
|
liveStatus: LiveStatusMap;
|
|
defaultOpenCategories: Set<string>;
|
|
filter: FilterMode;
|
|
referenceSlug: string;
|
|
connection?: ConnectionStatus;
|
|
}
|
|
|
|
/** Tier sort order — reference first. */
|
|
const TIER_ORDER: Record<ParityTier, number> = {
|
|
reference: 0,
|
|
at_parity: 1,
|
|
partial: 2,
|
|
minimal: 3,
|
|
not_wired: 4,
|
|
};
|
|
|
|
function sortIntegrations(integrations: IntegrationInfo[]): IntegrationInfo[] {
|
|
return [...integrations].sort((a, b) => {
|
|
const tierDiff = TIER_ORDER[a.tier] - TIER_ORDER[b.tier];
|
|
if (tierDiff !== 0) return tierDiff;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* CategorySection — one collapsible group of feature rows */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface CategorySectionProps {
|
|
cat: FeatureCategory & { features: FeatureInfo[] };
|
|
visibleIntegrations: IntegrationInfo[];
|
|
cellIndex: Map<string, CatalogCell>;
|
|
liveStatus: LiveStatusMap;
|
|
defaultOpen: boolean;
|
|
filter: FilterMode;
|
|
filterFeatureRow: (featureId: string) => boolean;
|
|
selectedCell: SelectedCell | null;
|
|
onCellClick: (cell: SelectedCell | null) => void;
|
|
connection: ConnectionStatus;
|
|
/**
|
|
* Single reference time shared with the parent's filter pass so the
|
|
* render-path `buildCellModel` and the filter `buildCellModel`/`resolveCell`
|
|
* agree on which green rows are stale — a cell the filter included can no
|
|
* longer render a different staleness state because its render `now` crossed
|
|
* a window boundary milliseconds later.
|
|
*/
|
|
now: number;
|
|
}
|
|
|
|
function CategorySection({
|
|
cat,
|
|
visibleIntegrations,
|
|
cellIndex,
|
|
liveStatus,
|
|
defaultOpen,
|
|
filter,
|
|
filterFeatureRow,
|
|
selectedCell,
|
|
onCellClick,
|
|
connection,
|
|
now,
|
|
}: CategorySectionProps) {
|
|
const { isOpen, toggle } = useCollapsible({
|
|
name: cat.name,
|
|
defaultOpen,
|
|
});
|
|
|
|
const wiredInCat = cat.features.reduce((acc, f) => {
|
|
return (
|
|
acc +
|
|
visibleIntegrations.filter((int) => {
|
|
const cell = cellIndex.get(`${int.slug}/${f.id}`);
|
|
return cell?.status === "wired";
|
|
}).length
|
|
);
|
|
}, 0);
|
|
const totalInCat = cat.features.length * visibleIntegrations.length;
|
|
|
|
const visibleFeatures = cat.features.filter((f) => filterFeatureRow(f.id));
|
|
const displayFeatures =
|
|
filter === "all" || filter === "reference" ? cat.features : visibleFeatures;
|
|
|
|
return (
|
|
<>
|
|
<CategoryHeaderRow
|
|
name={cat.name}
|
|
count={`${wiredInCat}/${totalInCat}`}
|
|
colSpan={visibleIntegrations.length + 1}
|
|
isOpen={isOpen}
|
|
onToggle={toggle}
|
|
/>
|
|
{isOpen &&
|
|
displayFeatures.map((feature) => (
|
|
<tr
|
|
key={feature.id}
|
|
className="border-t border-[var(--border)] hover:bg-[var(--bg-hover)]"
|
|
>
|
|
<td className="sticky left-0 z-10 bg-[var(--bg-surface)] px-4 py-1.5 border-r border-[var(--border)] align-middle min-w-[200px]">
|
|
<span className="text-xs text-[var(--text)]" title={feature.name}>
|
|
{feature.name}
|
|
</span>
|
|
</td>
|
|
{visibleIntegrations.map((int) => {
|
|
const cell = cellIndex.get(`${int.slug}/${feature.id}`);
|
|
const isNotSupported = cell?.status === "unsupported";
|
|
const model = buildCellModel(
|
|
liveStatus,
|
|
{
|
|
slug: int.slug,
|
|
featureId: feature.id,
|
|
isSupported: !isNotSupported,
|
|
isWired: cell?.status === "wired" || cell?.status === "stub",
|
|
},
|
|
now,
|
|
);
|
|
const cellStatus = !model.supported
|
|
? "unsupported"
|
|
: (cell?.status ?? "unshipped");
|
|
|
|
const isSelected =
|
|
selectedCell?.slug === int.slug &&
|
|
selectedCell?.featureId === feature.id;
|
|
|
|
return (
|
|
<td
|
|
key={int.slug}
|
|
className="border-l border-[var(--border)] px-3 py-1.5 align-middle text-center relative"
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid={`cell-btn-${int.slug}-${feature.id}`}
|
|
className="cursor-pointer bg-transparent border-none p-0"
|
|
onClick={() =>
|
|
isSelected
|
|
? onCellClick(null)
|
|
: onCellClick({
|
|
slug: int.slug,
|
|
featureId: feature.id,
|
|
integrationName: int.name,
|
|
featureName: feature.name,
|
|
})
|
|
}
|
|
>
|
|
<DepthChip
|
|
depth={model.achievedDepth as 0 | 1 | 2 | 3 | 4 | 5 | 6}
|
|
status={cellStatus}
|
|
chipColor={model.chipColor}
|
|
/>
|
|
</button>
|
|
{isSelected && (
|
|
<CellDrilldown
|
|
slug={int.slug}
|
|
featureId={feature.id}
|
|
integrationName={int.name}
|
|
featureName={feature.name}
|
|
liveStatus={liveStatus}
|
|
connection={connection}
|
|
onClose={() => onCellClick(null)}
|
|
/>
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* CellMatrix */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function CellMatrix({
|
|
cells,
|
|
categories,
|
|
features,
|
|
integrations,
|
|
liveStatus,
|
|
defaultOpenCategories,
|
|
filter,
|
|
referenceSlug,
|
|
connection = "live",
|
|
}: CellMatrixProps) {
|
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
|
const matrixRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close drilldown on click-outside
|
|
useEffect(() => {
|
|
if (!selectedCell) return;
|
|
function handleClickOutside(e: MouseEvent) {
|
|
const target = e.target as Node;
|
|
const drilldown = document.querySelector(
|
|
"[data-testid='cell-drilldown']",
|
|
);
|
|
if (drilldown && !drilldown.contains(target)) {
|
|
// Don't interfere with cell button clicks — their onClick handles toggle
|
|
const cellBtn = (target as Element).closest?.(
|
|
"[data-testid^='cell-btn-']",
|
|
);
|
|
if (cellBtn) return;
|
|
setSelectedCell(null);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [selectedCell]);
|
|
|
|
const handleCellClick = useCallback((cell: SelectedCell | null) => {
|
|
setSelectedCell(cell);
|
|
}, []);
|
|
|
|
const sortedIntegrations = useMemo(
|
|
() => sortIntegrations(integrations),
|
|
[integrations],
|
|
);
|
|
|
|
// Filter integrations by reference if filter=reference
|
|
const visibleIntegrations = useMemo(() => {
|
|
if (filter === "reference") {
|
|
return sortedIntegrations.filter((i) => i.slug === referenceSlug);
|
|
}
|
|
return sortedIntegrations;
|
|
}, [sortedIntegrations, filter, referenceSlug]);
|
|
|
|
// Index cells by integration+feature for O(1) lookup.
|
|
const cellIndex = useMemo(() => {
|
|
const idx = new Map<string, CatalogCell>();
|
|
for (const c of cells) {
|
|
idx.set(`${c.integration}/${c.feature}`, c);
|
|
}
|
|
return idx;
|
|
}, [cells]);
|
|
|
|
// Single reference time captured once per data update (keyed on
|
|
// `liveStatus`) and shared by the filter pass AND the render-path
|
|
// `buildCellModel` below. Threading ONE `now` keeps the chip and the
|
|
// badges in lockstep: a cell the filter included renders the SAME
|
|
// staleness state, instead of re-deriving against a fresh `Date.now()`
|
|
// that may have crossed a window boundary milliseconds later. Mirrors
|
|
// the single-`now` discipline in cells-view.tsx's stats pass.
|
|
const now = useMemo(() => Date.now(), [liveStatus]);
|
|
|
|
// Group features by category
|
|
const featuresByCategory = useMemo(() => {
|
|
return categories
|
|
.map((cat) => ({
|
|
...cat,
|
|
features: features.filter((f) => f.category === cat.id),
|
|
}))
|
|
.filter((cat) => cat.features.length > 0);
|
|
}, [categories, features]);
|
|
|
|
// Filter features based on mode
|
|
const filterFeatureRow = (featureId: string): boolean => {
|
|
if (filter === "all" || filter === "reference") return true;
|
|
// Reuse the single `now` hoisted to CellMatrix scope so the gaps
|
|
// `resolveCell` / regressions `buildCellModel` filter pass and the
|
|
// render-path `buildCellModel` (in CategorySection) all agree on which
|
|
// green rows are stale.
|
|
if (filter === "wired") {
|
|
return visibleIntegrations.some((int) => {
|
|
const cell = cellIndex.get(`${int.slug}/${featureId}`);
|
|
return cell && cell.status === "wired";
|
|
});
|
|
}
|
|
if (filter === "gaps") {
|
|
return visibleIntegrations.some((int) => {
|
|
const cell = cellIndex.get(`${int.slug}/${featureId}`);
|
|
// Unshipped = structural gap. Unsupported is NOT a gap — the
|
|
// framework architecturally cannot support it, so it's not work
|
|
// we expect to do.
|
|
if (!cell || cell.status === "unshipped") return true;
|
|
if (cell.status === "unsupported") return false;
|
|
// Red probes = functional gap (cell exists but failing)
|
|
if (cell.feature !== null) {
|
|
const cellState = resolveCell(liveStatus, int.slug, cell.feature, {
|
|
now,
|
|
});
|
|
if (cellState.rollup === "red") return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
if (filter === "regressions") {
|
|
return visibleIntegrations.some((int) => {
|
|
const cell = cellIndex.get(`${int.slug}/${featureId}`);
|
|
if (!cell) return false;
|
|
const isNotSupported = cell.status === "unsupported";
|
|
const model = buildCellModel(
|
|
liveStatus,
|
|
{
|
|
slug: int.slug,
|
|
featureId,
|
|
isSupported: !isNotSupported,
|
|
isWired: cell.status === "wired" || cell.status === "stub",
|
|
},
|
|
now,
|
|
);
|
|
return model.isRegression;
|
|
});
|
|
}
|
|
return true;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={matrixRef}
|
|
data-testid="cell-matrix"
|
|
className="overflow-auto rounded-lg border border-[var(--border)] bg-[var(--bg-surface)]"
|
|
>
|
|
<table className="border-collapse text-sm w-full">
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky left-0 top-0 z-30 bg-[var(--bg-muted)] px-4 py-3 text-left min-w-[200px] border-b border-[var(--border)]">
|
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
|
Feature
|
|
</span>
|
|
</th>
|
|
{visibleIntegrations.map((int) => (
|
|
<th
|
|
key={int.slug}
|
|
className="sticky top-0 z-20 bg-[var(--bg-muted)] px-3 py-3 text-left border-b border-l border-[var(--border)] font-normal min-w-[80px]"
|
|
>
|
|
<IntegrationHeader
|
|
slug={int.slug}
|
|
name={int.name}
|
|
tier={int.tier}
|
|
/>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{featuresByCategory.map((cat) => {
|
|
const visibleFeatures = cat.features.filter((f) =>
|
|
filterFeatureRow(f.id),
|
|
);
|
|
if (visibleFeatures.length === 0 && filter !== "all") return null;
|
|
|
|
return (
|
|
<CategorySection
|
|
key={cat.id}
|
|
cat={cat}
|
|
visibleIntegrations={visibleIntegrations}
|
|
cellIndex={cellIndex}
|
|
liveStatus={liveStatus}
|
|
defaultOpen={defaultOpenCategories.has(cat.id)}
|
|
filter={filter}
|
|
filterFeatureRow={filterFeatureRow}
|
|
selectedCell={selectedCell}
|
|
onCellClick={handleCellClick}
|
|
connection={connection}
|
|
now={now}
|
|
/>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|