@elabs-ai/components-process 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,148 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { CaseTable } from "./case-table";
5
+ import { casesFromLog } from "../core/cases-from-log";
6
+ import fixture from "../core/fixtures/order-to-cash-small.json";
7
+ import type { EventLog } from "../core/types";
8
+ import type { CaseRow } from "../core/cases-from-log";
9
+
10
+ afterEach(cleanup);
11
+
12
+ const orderToCash = fixture as EventLog;
13
+
14
+ const CASES: CaseRow[] = [
15
+ {
16
+ caseId: "case-1",
17
+ start: "2026-01-05T09:00:00.000Z",
18
+ end: "2026-01-05T14:00:00.000Z",
19
+ durationMs: 18_000_000,
20
+ eventCount: 6,
21
+ variantId: "v1",
22
+ conformance: "conforming",
23
+ },
24
+ {
25
+ caseId: "case-2",
26
+ start: "2026-01-06T09:00:00.000Z",
27
+ end: "2026-01-06T14:00:00.000Z",
28
+ durationMs: 18_000_000,
29
+ eventCount: 6,
30
+ variantId: "v1",
31
+ conformance: "nonConforming",
32
+ },
33
+ ];
34
+
35
+ describe("CaseTable — rendering", () => {
36
+ it("renders the default column headers", () => {
37
+ render(<CaseTable cases={CASES} />);
38
+ expect(screen.getByText("Case")).toBeInTheDocument();
39
+ expect(screen.getByText("Start")).toBeInTheDocument();
40
+ expect(screen.getByText("End")).toBeInTheDocument();
41
+ expect(screen.getByText("Duration")).toBeInTheDocument();
42
+ expect(screen.getByText("Events")).toBeInTheDocument();
43
+ expect(screen.getByText("Variant")).toBeInTheDocument();
44
+ expect(screen.getByText("Conformance")).toBeInTheDocument();
45
+ });
46
+
47
+ it("prints every case row's id", () => {
48
+ render(<CaseTable cases={CASES} />);
49
+ expect(screen.getByText("case-1")).toBeInTheDocument();
50
+ expect(screen.getByText("case-2")).toBeInTheDocument();
51
+ });
52
+
53
+ it("renders the conformance badge with a non-colour (text) channel", () => {
54
+ render(<CaseTable cases={CASES} />);
55
+ expect(screen.getByText("Conforming")).toBeInTheDocument();
56
+ expect(screen.getByText("Non-conforming")).toBeInTheDocument();
57
+ });
58
+
59
+ it("renders an empty state and not the table body when cases is empty", () => {
60
+ render(<CaseTable cases={[]} />);
61
+ expect(screen.getByText("No cases to display.")).toBeInTheDocument();
62
+ });
63
+
64
+ it("shows a loading state when loading", () => {
65
+ render(<CaseTable cases={[]} loading />);
66
+ expect(screen.getByRole("table")).toHaveAttribute("aria-busy", "true");
67
+ });
68
+ });
69
+
70
+ describe("CaseTable — row activation (#204 acceptance)", () => {
71
+ it("calls onCaseOpen with the row's caseId on click", async () => {
72
+ const onCaseOpen = vi.fn();
73
+ const user = userEvent.setup();
74
+ render(<CaseTable cases={CASES} onCaseOpen={onCaseOpen} />);
75
+ // The first cell renders both the visible "case-2" text AND the row's sr-only
76
+ // activation button, which is also named "case-2" (#337) — take the visible one.
77
+ const [visibleCell] = screen.getAllByText("case-2");
78
+ await user.click(visibleCell as HTMLElement);
79
+ expect(onCaseOpen).toHaveBeenCalledWith("case-2");
80
+ });
81
+
82
+ it("calls onCaseOpen on keyboard activation (Tab to the row, Enter)", async () => {
83
+ const onCaseOpen = vi.fn();
84
+ render(<CaseTable cases={CASES} onCaseOpen={onCaseOpen} />);
85
+
86
+ // DataTable's row-activation button is the row's own tab stop, named after the
87
+ // row's first cell (#337) — focus it directly rather than counting Tab stops
88
+ // (sortable column headers are also tab stops ahead of it in DOM order).
89
+ const rowAction = screen.getByRole("button", { name: "case-1" });
90
+ rowAction.focus();
91
+ await userEvent.keyboard("{Enter}");
92
+ expect(onCaseOpen).toHaveBeenCalledWith("case-1");
93
+ });
94
+
95
+ it("does not add a row activation target when onCaseOpen is omitted", () => {
96
+ render(<CaseTable cases={CASES} />);
97
+ expect(screen.queryByRole("button", { name: "case-1" })).not.toBeInTheDocument();
98
+ });
99
+ });
100
+
101
+ describe("CaseTable — CSV export (#204 acceptance: column order matches the configured columns)", () => {
102
+ let clickSpy: ReturnType<typeof vi.spyOn>;
103
+ // jsdom's `Blob` doesn't implement `.text()`; stub the constructor so the export
104
+ // handler's `new Blob([csv], {...})` hands back the raw CSV string directly.
105
+ class RecordingBlob {
106
+ parts: BlobPart[];
107
+ constructor(parts: BlobPart[]) {
108
+ this.parts = parts;
109
+ }
110
+ }
111
+
112
+ beforeEach(() => {
113
+ vi.stubGlobal("Blob", RecordingBlob);
114
+ global.URL.createObjectURL = vi.fn(() => "blob:mock");
115
+ global.URL.revokeObjectURL = vi.fn();
116
+ clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
117
+ });
118
+
119
+ afterEach(() => {
120
+ vi.unstubAllGlobals();
121
+ });
122
+
123
+ it("exports the visible column set, in the configured order, attribute columns included", async () => {
124
+ const user = userEvent.setup();
125
+ const rows = casesFromLog(orderToCash);
126
+ render(
127
+ <CaseTable
128
+ cases={rows}
129
+ columns={[
130
+ { accessorKey: "variantId", header: "Variant" },
131
+ { accessorKey: "caseId", header: "Case" },
132
+ ]}
133
+ />,
134
+ );
135
+ await user.click(screen.getByRole("button", { name: "Export CSV" }));
136
+ expect(clickSpy).toHaveBeenCalled();
137
+ const created = (global.URL.createObjectURL as ReturnType<typeof vi.fn>).mock
138
+ .calls[0]?.[0] as RecordingBlob;
139
+ const csv = created.parts[0] as string;
140
+ const [header] = csv.split("\r\n");
141
+ expect(header).toBe("Variant,Case");
142
+ });
143
+
144
+ it("is disabled with nothing to export", () => {
145
+ render(<CaseTable cases={[]} />);
146
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
147
+ });
148
+ });
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CaseTable — the case drill-down every auditor persona needs (RM-055, issue #204, §4 R11).
5
+ *
6
+ * A column CONFIGURATION over `@elabs-ai/components-data`'s `DataTable`, not a table
7
+ * implementation (`.claude/rules/data.md` — "primitives go down, compositions go up"):
8
+ * this file authors no `<table>` markup of its own. Row activation, keyboard navigation,
9
+ * loading/empty states and the CSV column-order contract all come straight from `DataTable`;
10
+ * the only thing this component adds is the domain reading (which field is a column, how a
11
+ * duration/conformance value prints) and the export button.
12
+ *
13
+ * ## CSV export mirrors the VISIBLE columns
14
+ *
15
+ * `toCsv` is called with the SAME column set the table renders (the default set, or the
16
+ * caller's own `columns` override) — in the same order, with the same header text — so a
17
+ * consumer who narrows or reorders `columns` gets an export that matches what is on screen,
18
+ * attribute columns included. Header/value resolution reads a `ColumnDef`'s own
19
+ * `accessorKey`/`accessorFn` directly rather than re-deriving through a live table instance,
20
+ * which keeps this a pure per-row mapping (no TanStack `Table` needed for the export path).
21
+ */
22
+ import { forwardRef, useCallback, useMemo, type HTMLAttributes, type ReactNode } from "react";
23
+ import { Download } from "lucide-react";
24
+ import { DataTable, toCsv, type ColumnDef } from "@elabs-ai/components-data";
25
+ import { Button, downloadBlob, useLocale } from "@elabs-ai/components-ui";
26
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
27
+ import type { CaseRow } from "../core/cases-from-log";
28
+ import { createCaseTableColumns } from "./columns";
29
+
30
+ export interface CaseTableProps extends HTMLAttributes<HTMLDivElement> {
31
+ cases: CaseRow[];
32
+ /** Column configuration over `DataTable`. Defaults to `createCaseTableColumns`'s set. */
33
+ columns?: ColumnDef<CaseRow>[];
34
+ /** Fired when a row is activated (click, or Enter/Space on its keyboard activation target). */
35
+ onCaseOpen?: (caseId: string) => void;
36
+ /** File name for the CSV export. Default `"cases.csv"`. */
37
+ exportFileName?: string;
38
+ loading?: boolean;
39
+ /** Message shown when there are no cases and not loading. */
40
+ emptyMessage?: ReactNode;
41
+ }
42
+
43
+ type CaseTableColumn = ColumnDef<CaseRow> & {
44
+ id?: string;
45
+ accessorKey?: string;
46
+ accessorFn?: (row: CaseRow, index: number) => unknown;
47
+ };
48
+
49
+ function csvKey(column: CaseTableColumn): string {
50
+ return column.id ?? column.accessorKey ?? "";
51
+ }
52
+
53
+ function csvHeader(column: CaseTableColumn): string {
54
+ return typeof column.header === "string" ? column.header : csvKey(column);
55
+ }
56
+
57
+ function csvValue(row: CaseRow, column: CaseTableColumn): string | number | boolean | null {
58
+ const value =
59
+ typeof column.accessorFn === "function"
60
+ ? column.accessorFn(row, 0)
61
+ : typeof column.accessorKey === "string"
62
+ ? (row as unknown as Record<string, unknown>)[column.accessorKey]
63
+ : undefined;
64
+ if (value === null || value === undefined) return null;
65
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
66
+ return value;
67
+ }
68
+ return String(value);
69
+ }
70
+
71
+ function toCsvRecord(
72
+ row: CaseRow,
73
+ columns: CaseTableColumn[],
74
+ ): Record<string, string | number | boolean | null> {
75
+ const record: Record<string, string | number | boolean | null> = {};
76
+ for (const column of columns) {
77
+ const key = csvKey(column);
78
+ if (key) record[key] = csvValue(row, column);
79
+ }
80
+ return record;
81
+ }
82
+
83
+ export const CaseTable = forwardRef<HTMLDivElement, CaseTableProps>(function CaseTable(
84
+ {
85
+ cases,
86
+ columns,
87
+ onCaseOpen,
88
+ exportFileName = "cases.csv",
89
+ loading = false,
90
+ emptyMessage,
91
+ className,
92
+ ...props
93
+ },
94
+ ref,
95
+ ) {
96
+ const { t, formatDate } = useLocale();
97
+
98
+ const resolvedColumns = useMemo<CaseTableColumn[]>(
99
+ () => columns ?? createCaseTableColumns({ t, formatDate }),
100
+ [columns, t, formatDate],
101
+ );
102
+
103
+ const handleExport = useCallback(() => {
104
+ const csvColumns = resolvedColumns.map((column) => ({
105
+ key: csvKey(column),
106
+ header: csvHeader(column),
107
+ }));
108
+ const rows = cases.map((row) => toCsvRecord(row, resolvedColumns));
109
+ const csv = toCsv(rows, { columns: csvColumns });
110
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
111
+ downloadBlob(blob, `${exportFileName.replace(/\.csv$/i, "")}.csv`);
112
+ }, [cases, resolvedColumns, exportFileName]);
113
+
114
+ return (
115
+ <div
116
+ ref={ref}
117
+ data-slot="case-table"
118
+ className={cn("flex flex-col gap-2", className)}
119
+ {...props}
120
+ >
121
+ <div data-slot="case-table-toolbar" className="flex justify-end">
122
+ <Button
123
+ type="button"
124
+ variant="outline"
125
+ size="sm"
126
+ onClick={handleExport}
127
+ disabled={cases.length === 0}
128
+ >
129
+ <Download aria-hidden="true" />
130
+ {t("process.caseTable.exportCsv")}
131
+ </Button>
132
+ </div>
133
+ <DataTable<CaseRow, unknown>
134
+ data={cases}
135
+ columns={resolvedColumns}
136
+ getRowId={(row) => row.caseId}
137
+ loading={loading}
138
+ emptyMessage={emptyMessage ?? t("process.caseTable.empty")}
139
+ onRowClick={onCaseOpen ? (row) => onCaseOpen(row.original.caseId) : undefined}
140
+ caption={t("process.caseTable.tableLabel")}
141
+ />
142
+ </div>
143
+ );
144
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `CaseTable`'s default column set — RM-055.
3
+ *
4
+ * A column CONFIGURATION over `@elabs-ai/components-data`'s `DataTable`, not a table
5
+ * implementation: every cell either prints a `CaseRow` field as-is or formats it with a
6
+ * helper this package already ships (`formatDurationMs`), plus one `StatusBadge` for the
7
+ * three-state conformance flag. See `.claude/rules/data.md` — "primitives go down,
8
+ * compositions go up". Plain `.ts` (no JSX) — the one cell that renders a component
9
+ * (`conformance`) builds it with `createElement`.
10
+ *
11
+ * Message KEYS, not resolved text, at module scope (mirrors `gantt.tsx`'s
12
+ * `VIEW_MODE_LABEL_KEYS` — `t()` needs a hook, unavailable here); `createCaseTableColumns`
13
+ * resolves them at the call site, which always has `useLocale()`.
14
+ */
15
+ import { createElement } from "react";
16
+ import { CheckCircle2, AlertCircle, type LucideIcon } from "lucide-react";
17
+ import { type ColumnDef } from "@elabs-ai/components-data";
18
+ import { StatusBadge, type LocaleContextValue, type StatusTone } from "@elabs-ai/components-ui";
19
+ import type { CaseRow } from "../core/cases-from-log";
20
+ import { formatDurationMs } from "../process-map/map-model";
21
+
22
+ export const CASE_TABLE_COLUMN_LABEL_KEYS = {
23
+ caseId: "process.caseTable.columnCaseId",
24
+ start: "process.caseTable.columnStart",
25
+ end: "process.caseTable.columnEnd",
26
+ duration: "process.caseTable.columnDuration",
27
+ eventCount: "process.caseTable.columnEventCount",
28
+ variantId: "process.caseTable.columnVariant",
29
+ conformance: "process.caseTable.columnConformance",
30
+ } as const;
31
+
32
+ const CONFORMANCE_LABEL_KEYS: Record<NonNullable<CaseRow["conformance"]>, string> = {
33
+ conforming: "process.caseTable.conformanceConforming",
34
+ nonConforming: "process.caseTable.conformanceNonConforming",
35
+ unknown: "process.caseTable.conformanceUnknown",
36
+ };
37
+
38
+ /**
39
+ * Maps a `CaseRow.conformance` value onto `StatusBadge`'s calm-only escape hatch (#363) —
40
+ * a domain-specific three-state flag, not one of `StatusBadge`'s own canonical 7 statuses.
41
+ */
42
+ function conformanceStatus(
43
+ conformance: CaseRow["conformance"],
44
+ t: LocaleContextValue["t"],
45
+ ): { label: string; tone: StatusTone; icon?: LucideIcon } {
46
+ switch (conformance) {
47
+ case "conforming":
48
+ return { label: t(CONFORMANCE_LABEL_KEYS.conforming), tone: "success", icon: CheckCircle2 };
49
+ case "nonConforming":
50
+ return {
51
+ label: t(CONFORMANCE_LABEL_KEYS.nonConforming),
52
+ tone: "destructive",
53
+ icon: AlertCircle,
54
+ };
55
+ case "unknown":
56
+ default:
57
+ return { label: t(CONFORMANCE_LABEL_KEYS.unknown), tone: "neutral" };
58
+ }
59
+ }
60
+
61
+ export interface CreateCaseTableColumnsOptions {
62
+ t: LocaleContextValue["t"];
63
+ formatDate: LocaleContextValue["formatDate"];
64
+ }
65
+
66
+ /** The default `CaseTable` column set. Reproduce this shape (or subset it) to customize. */
67
+ export function createCaseTableColumns({
68
+ t,
69
+ formatDate,
70
+ }: CreateCaseTableColumnsOptions): ColumnDef<CaseRow>[] {
71
+ return [
72
+ {
73
+ accessorKey: "caseId",
74
+ header: t(CASE_TABLE_COLUMN_LABEL_KEYS.caseId),
75
+ },
76
+ {
77
+ accessorKey: "start",
78
+ header: t(CASE_TABLE_COLUMN_LABEL_KEYS.start),
79
+ cell: ({ getValue }) => {
80
+ const value = getValue<string>();
81
+ return value ? formatDate(new Date(value)) : "—";
82
+ },
83
+ },
84
+ {
85
+ accessorKey: "end",
86
+ header: t(CASE_TABLE_COLUMN_LABEL_KEYS.end),
87
+ cell: ({ getValue }) => {
88
+ const value = getValue<string>();
89
+ return value ? formatDate(new Date(value)) : "—";
90
+ },
91
+ },
92
+ {
93
+ accessorKey: "durationMs",
94
+ header: t(CASE_TABLE_COLUMN_LABEL_KEYS.duration),
95
+ meta: { numeric: true },
96
+ cell: ({ getValue }) => formatDurationMs(getValue<number>()),
97
+ },
98
+ {
99
+ accessorKey: "eventCount",
100
+ header: t(CASE_TABLE_COLUMN_LABEL_KEYS.eventCount),
101
+ meta: { numeric: true },
102
+ },
103
+ {
104
+ accessorKey: "variantId",
105
+ header: t(CASE_TABLE_COLUMN_LABEL_KEYS.variantId),
106
+ },
107
+ {
108
+ accessorKey: "conformance",
109
+ header: t(CASE_TABLE_COLUMN_LABEL_KEYS.conformance),
110
+ cell: ({ getValue }) => {
111
+ const status = conformanceStatus(getValue<CaseRow["conformance"]>(), t);
112
+ return createElement(StatusBadge, { status, size: "sm" });
113
+ },
114
+ },
115
+ ];
116
+ }
@@ -0,0 +1,11 @@
1
+ export { CaseTable } from "./case-table";
2
+ export type { CaseTableProps } from "./case-table";
3
+
4
+ export { CASE_TABLE_COLUMN_LABEL_KEYS, createCaseTableColumns } from "./columns";
5
+ export type { CreateCaseTableColumnsOptions } from "./columns";
6
+
7
+ // `CaseRow`/`casesFromLog` are canonically defined in `../core/cases-from-log` (framework-free)
8
+ // — re-exported here so the trunk barrel (`@elabs-ai/components-process`) surfaces them
9
+ // alongside `CaseTable` without a caller needing the `/core` subpath.
10
+ export { casesFromLog } from "../core/cases-from-log";
11
+ export type { CaseRow } from "../core/cases-from-log";
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCaseTimelineInstances } from "./case-timeline-model";
3
+ import type { EventRow } from "../core/types";
4
+
5
+ const CASE_ID = "case-1";
6
+
7
+ // A (0 – 30min), 30min wait, B (1h – 1.5h), 30min wait, C (2h – 3h) overlapping D
8
+ // (2.5h – 3.5h) by 30min — the fixture the RM-055 acceptance criterion asks for: "two
9
+ // waiting periods" and "one pair of instances flagged parallel".
10
+ const EVENTS: EventRow[] = [
11
+ { caseId: CASE_ID, activity: "A", startTimestamp: 0, timestamp: 1_800_000 },
12
+ { caseId: CASE_ID, activity: "B", startTimestamp: 3_600_000, timestamp: 5_400_000 },
13
+ { caseId: CASE_ID, activity: "C", startTimestamp: 7_200_000, timestamp: 10_800_000 },
14
+ { caseId: CASE_ID, activity: "D", startTimestamp: 9_000_000, timestamp: 12_600_000 },
15
+ ];
16
+
17
+ describe("buildCaseTimelineInstances", () => {
18
+ it("answers an empty array for a case with no resolvable rows", () => {
19
+ expect(buildCaseTimelineInstances([])).toEqual([]);
20
+ });
21
+
22
+ it("builds one instance per activity execution, in chronological order", () => {
23
+ const instances = buildCaseTimelineInstances(EVENTS);
24
+ expect(instances.map((i) => i.activity)).toEqual(["A", "B", "C", "D"]);
25
+ expect(instances.map((i) => [i.start, i.end])).toEqual([
26
+ [0, 1_800_000],
27
+ [3_600_000, 5_400_000],
28
+ [7_200_000, 10_800_000],
29
+ [9_000_000, 12_600_000],
30
+ ]);
31
+ });
32
+
33
+ it("renders exactly two waiting-time gaps for the fixture", () => {
34
+ const instances = buildCaseTimelineInstances(EVENTS);
35
+ const gaps = instances.map((i) => i.gap);
36
+ expect(gaps[0]).toBeUndefined(); // A — nothing preceded it
37
+ expect(gaps[1]).toEqual({ start: 1_800_000, end: 3_600_000, durationMs: 1_800_000 }); // before B
38
+ expect(gaps[2]).toEqual({ start: 5_400_000, end: 7_200_000, durationMs: 1_800_000 }); // before C
39
+ expect(gaps[3]).toBeUndefined(); // D starts DURING C — no waiting time, they overlap
40
+ });
41
+
42
+ it("flags exactly the one overlapping pair (C, D) as parallel by default", () => {
43
+ const instances = buildCaseTimelineInstances(EVENTS);
44
+ const flagged = instances.filter((i) => i.isParallel).map((i) => i.activity);
45
+ expect(flagged.sort()).toEqual(["C", "D"]);
46
+ });
47
+
48
+ it("does not flag an overlap at or below parallelismThreshold", () => {
49
+ // C/D overlap by exactly 1_800_000ms — a threshold equal to the overlap must NOT flag it
50
+ // ("below which two instances are NOT flagged parallel" — the boundary itself doesn't flag).
51
+ const instances = buildCaseTimelineInstances(EVENTS, { parallelismThreshold: 1_800_000 });
52
+ expect(instances.every((i) => !i.isParallel)).toBe(true);
53
+ });
54
+
55
+ it("flags an overlap above the threshold", () => {
56
+ const instances = buildCaseTimelineInstances(EVENTS, { parallelismThreshold: 1_000_000 });
57
+ expect(
58
+ instances
59
+ .filter((i) => i.isParallel)
60
+ .map((i) => i.activity)
61
+ .sort(),
62
+ ).toEqual(["C", "D"]);
63
+ });
64
+
65
+ it("marks an unterminated lifecycle:start instance as open", () => {
66
+ const instances = buildCaseTimelineInstances([
67
+ { caseId: CASE_ID, activity: "A", timestamp: 0, lifecycle: "start" },
68
+ ]);
69
+ expect(instances).toHaveLength(1);
70
+ expect(instances[0]?.isOpen).toBe(true);
71
+ });
72
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `CaseTimeline`'s model — RM-055.
3
+ *
4
+ * Maps ONE case's raw `EventRow[]` to a flat, chronologically-ordered `CaseTimelineInstance[]`:
5
+ * one entry per activity EXECUTION (not per activity name — a repeated activity gets one
6
+ * instance per occurrence), each carrying the resolved start/end `normalizeLog` (RM-049)
7
+ * already knows how to pair from `lifecycle: "start"`/`"complete"` rows.
8
+ *
9
+ * Two things this module adds on top of `normalizeLog`'s own trace:
10
+ *
11
+ * - **Waiting-time gaps** — the idle time between the END of one instance and the START of
12
+ * the NEXT in the case's own chronological order (not "next occurrence of the same
13
+ * activity" — a case has no natural per-activity lane once instances interleave). Surfaced
14
+ * as `gap` on the LATER instance, ready to become one `Gantt` `row.gaps` entry (RM-047).
15
+ * - **Parallel flags** — any pair of instances whose intervals overlap by more than
16
+ * `parallelismThreshold` (default `0`: any genuine overlap) are both marked `isParallel`.
17
+ * O(n²) over one case's own instance count, which is small enough that this never needs
18
+ * the interval-tree machinery a whole-log analysis would.
19
+ *
20
+ * Plain `.ts` (no JSX, no `@elabs-ai/components-charts` import) — `case-timeline.tsx` is the
21
+ * one place that turns an instance into a `Gantt` `GanttTask`, because that is also where the
22
+ * parallel flag's localized text channel (WCAG 1.4.1 — colour is never the only channel) is
23
+ * built, and `useLocale()` is only available from a component.
24
+ */
25
+ import { normalizeLog } from "../core/event-log";
26
+ import type { EventRow } from "../core/types";
27
+
28
+ export interface CaseTimelineGap {
29
+ start: number;
30
+ end: number;
31
+ durationMs: number;
32
+ }
33
+
34
+ /** One activity EXECUTION in the case's trace. */
35
+ export interface CaseTimelineInstance {
36
+ /** Stable within one model build — the instance's position in chronological order. */
37
+ id: string;
38
+ activity: string;
39
+ start: number;
40
+ end: number;
41
+ resource?: string;
42
+ /** True for a `lifecycle: "start"` row that never got a matching `"complete"`. */
43
+ isOpen: boolean;
44
+ /** True when this instance's interval overlaps another by more than `parallelismThreshold`. */
45
+ isParallel: boolean;
46
+ /** Waiting time since the previous instance ended, when there was a gap. */
47
+ gap?: CaseTimelineGap;
48
+ }
49
+
50
+ export interface CaseTimelineModelOptions {
51
+ /**
52
+ * Ms of overlap below which two instances are NOT flagged parallel. Default `0` — any
53
+ * genuine overlap (however small) counts.
54
+ */
55
+ parallelismThreshold?: number;
56
+ }
57
+
58
+ /**
59
+ * One case's trace as `CaseTimelineInstance[]`, in ascending `start` order — empty when
60
+ * `events` resolves to no case (e.g. every row has an empty `caseId`/`activity`).
61
+ */
62
+ export function buildCaseTimelineInstances(
63
+ events: EventRow[],
64
+ options: CaseTimelineModelOptions = {},
65
+ ): CaseTimelineInstance[] {
66
+ const threshold = options.parallelismThreshold ?? 0;
67
+ const normalized = normalizeLog({ events });
68
+ const kase = normalized.cases[0];
69
+ if (!kase) return [];
70
+
71
+ const instances: CaseTimelineInstance[] = kase.events.map((event, index) => {
72
+ const instance: CaseTimelineInstance = {
73
+ id: `${index}`,
74
+ activity: event.activity,
75
+ start: event.start,
76
+ end: event.end,
77
+ isOpen: event.isOpen,
78
+ isParallel: false,
79
+ };
80
+ if (event.resource !== undefined) instance.resource = event.resource;
81
+ return instance;
82
+ });
83
+
84
+ // Waiting-time gaps: chronological order, not "same activity" — `kase.events` is already
85
+ // sorted by resolved start (`normalizeLog`'s own contract).
86
+ for (let i = 1; i < instances.length; i += 1) {
87
+ const previous = instances[i - 1] as CaseTimelineInstance;
88
+ const current = instances[i] as CaseTimelineInstance;
89
+ if (current.start > previous.end) {
90
+ current.gap = {
91
+ start: previous.end,
92
+ end: current.start,
93
+ durationMs: current.start - previous.end,
94
+ };
95
+ }
96
+ }
97
+
98
+ // Parallel flags: every pair whose intervals overlap by more than `threshold`.
99
+ for (let i = 0; i < instances.length; i += 1) {
100
+ for (let j = i + 1; j < instances.length; j += 1) {
101
+ const a = instances[i] as CaseTimelineInstance;
102
+ const b = instances[j] as CaseTimelineInstance;
103
+ const overlap = Math.min(a.end, b.end) - Math.max(a.start, b.start);
104
+ if (overlap > threshold) {
105
+ a.isParallel = true;
106
+ b.isParallel = true;
107
+ }
108
+ }
109
+ }
110
+
111
+ return instances;
112
+ }