@elabs-ai/components-process 4.2.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 (144) 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__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -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
+ }
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { CaseTimeline } from "./case-timeline";
3
+ import type { EventRow } from "../core/types";
4
+
5
+ const CASE_ID = "case-1";
6
+
7
+ /** Three activities, back to back — no waiting time, no overlap. */
8
+ const SEQUENTIAL_EVENTS: EventRow[] = [
9
+ { caseId: CASE_ID, activity: "Create Order", startTimestamp: 0, timestamp: 1_800_000 },
10
+ {
11
+ caseId: CASE_ID,
12
+ activity: "Check Credit",
13
+ startTimestamp: 1_800_000,
14
+ timestamp: 3_600_000,
15
+ },
16
+ {
17
+ caseId: CASE_ID,
18
+ activity: "Approve Order",
19
+ startTimestamp: 3_600_000,
20
+ timestamp: 5_400_000,
21
+ },
22
+ ];
23
+
24
+ /** Two gaps: the case waits between every pair of activities. */
25
+ const WAITING_TIME_EVENTS: EventRow[] = [
26
+ { caseId: CASE_ID, activity: "Create Order", startTimestamp: 0, timestamp: 1_800_000 },
27
+ {
28
+ caseId: CASE_ID,
29
+ activity: "Check Credit",
30
+ startTimestamp: 3_600_000,
31
+ timestamp: 5_400_000,
32
+ },
33
+ {
34
+ caseId: CASE_ID,
35
+ activity: "Approve Order",
36
+ startTimestamp: 9_000_000,
37
+ timestamp: 10_800_000,
38
+ },
39
+ ];
40
+
41
+ /** "Check Credit" and "Check Inventory" run at the same time — both flag parallel. */
42
+ const PARALLEL_EVENTS: EventRow[] = [
43
+ { caseId: CASE_ID, activity: "Check Credit", startTimestamp: 0, timestamp: 3_600_000 },
44
+ {
45
+ caseId: CASE_ID,
46
+ activity: "Check Inventory",
47
+ startTimestamp: 1_800_000,
48
+ timestamp: 5_400_000,
49
+ },
50
+ {
51
+ caseId: CASE_ID,
52
+ activity: "Approve Order",
53
+ startTimestamp: 7_200_000,
54
+ timestamp: 9_000_000,
55
+ },
56
+ ];
57
+
58
+ const meta = {
59
+ title: "Process/CaseTimeline",
60
+ component: CaseTimeline,
61
+ tags: ["autodocs"],
62
+ parameters: {
63
+ docs: {
64
+ description: {
65
+ component:
66
+ "A thin wrapper over `@elabs-ai/components-charts`'s `Gantt` (§4 R12): one row per " +
67
+ "activity instance, waiting time as RM-047 gap bands, overlapping instances flagged " +
68
+ "parallel with an existing `Gantt` tone plus a visible, non-colour text tag.",
69
+ },
70
+ },
71
+ },
72
+ } satisfies Meta<typeof CaseTimeline>;
73
+ export default meta;
74
+ type Story = StoryObj<typeof meta>;
75
+
76
+ /** No waiting time, no overlap — the plain case. */
77
+ export const Sequential: Story = {
78
+ args: { caseId: CASE_ID, events: SEQUENTIAL_EVENTS },
79
+ };
80
+
81
+ /** Two hatched gap bands render the idle time between activities (RM-047). */
82
+ export const WithWaitingTime: Story = {
83
+ args: { caseId: CASE_ID, events: WAITING_TIME_EVENTS },
84
+ };
85
+
86
+ /** "Check Credit" and "Check Inventory" overlap — both render the parallel tag. */
87
+ export const ParallelActivities: Story = {
88
+ args: { caseId: CASE_ID, events: PARALLEL_EVENTS },
89
+ };
90
+
91
+ /** No events for this case yet — `Gantt`'s own built-in empty state. */
92
+ export const Empty: Story = {
93
+ args: { caseId: CASE_ID, events: [] },
94
+ };
@@ -0,0 +1,51 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { CaseTimeline } from "./case-timeline";
4
+ import type { EventRow } from "../core/types";
5
+
6
+ afterEach(cleanup);
7
+
8
+ const CASE_ID = "case-1";
9
+
10
+ const PARALLEL_EVENTS: EventRow[] = [
11
+ { caseId: CASE_ID, activity: "Check Credit", startTimestamp: 0, timestamp: 3_600_000 },
12
+ {
13
+ caseId: CASE_ID,
14
+ activity: "Check Inventory",
15
+ startTimestamp: 1_800_000,
16
+ timestamp: 5_400_000,
17
+ },
18
+ { caseId: CASE_ID, activity: "Approve Order", startTimestamp: 7_200_000, timestamp: 9_000_000 },
19
+ ];
20
+
21
+ describe("CaseTimeline — rendering", () => {
22
+ it("renders one row per activity instance", () => {
23
+ render(<CaseTimeline caseId={CASE_ID} events={PARALLEL_EVENTS} />);
24
+ const treeitems = screen.getAllByRole("treeitem");
25
+ expect(treeitems).toHaveLength(3);
26
+ });
27
+
28
+ it("renders the empty state when the case has no events", () => {
29
+ render(<CaseTimeline caseId={CASE_ID} events={[]} />);
30
+ expect(screen.getByText(/no tasks to display/i)).toBeInTheDocument();
31
+ });
32
+
33
+ it("flags overlapping instances with a visible, non-colour 'Parallel' tag (WCAG 1.4.1)", () => {
34
+ render(<CaseTimeline caseId={CASE_ID} events={PARALLEL_EVENTS} />);
35
+ // Two overlapping instances ("Check Credit" and "Check Inventory") each carry the
36
+ // text tag; "Approve Order" (no overlap) does not.
37
+ const parallelTags = screen.getAllByText("Parallel");
38
+ expect(parallelTags.length).toBeGreaterThanOrEqual(2);
39
+
40
+ const treeitems = screen.getAllByRole("treeitem");
41
+ const approveOrderRow = treeitems.find((el) => el.textContent?.includes("Approve Order"));
42
+ expect(approveOrderRow?.textContent).not.toContain("Parallel");
43
+ });
44
+
45
+ it("respects a custom parallelismThreshold", () => {
46
+ render(
47
+ <CaseTimeline caseId={CASE_ID} events={PARALLEL_EVENTS} parallelismThreshold={10_000_000} />,
48
+ );
49
+ expect(screen.queryByText("Parallel")).not.toBeInTheDocument();
50
+ });
51
+ });