Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
14313d5
feat(component): add number formatting to single value and tooltips
alfredorubin96 Mar 22, 2026
7dd15c4
feat(component): add DataZoom support for bar and line charts
alfredorubin96 Mar 22, 2026
b826fac
feat(component): auto-rotate and truncate axis labels for bar chart
alfredorubin96 Mar 22, 2026
0672191
feat(component): add reference lines (markLine) for bar and line charts
alfredorubin96 Mar 22, 2026
0a584cd
feat(component): add donut center text and Top-N grouping for pie chart
alfredorubin96 Mar 22, 2026
ec704b6
feat(component): accessibility improvements — ARIA, keyboard nav, con…
alfredorubin96 Mar 23, 2026
980f138
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
670bd51
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
22975a5
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
a58b1cf
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
c5edb20
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
d6305d8
fix(component,app): bracket wrapping in code editor + radar chart scale
alfredorubin96 Mar 23, 2026
5c166f6
chore: add Chart Catalog seed dashboard with full feature showcase
alfredorubin96 Mar 23, 2026
6dd50c9
fix(component): resolve nested button hydration error in CrossFilterTag
alfredorubin96 Mar 23, 2026
f31270f
chore: expand Chart Catalog to cover ALL chart options comprehensively
alfredorubin96 Mar 23, 2026
943834a
chore: add Map Chart page with geo data (filming locations + birthpla…
alfredorubin96 Mar 23, 2026
b7513f0
fix: markdown newlines and iframe URL in Chart Catalog seed
alfredorubin96 Mar 23, 2026
89dbb1d
fix: markdown table rendering, graph performance, and query typo
alfredorubin96 Mar 23, 2026
57b05f0
fix(component): prevent graph chart nodes clumping on initial render
alfredorubin96 Mar 23, 2026
bbea43b
feat(component): add number formatting to single value and tooltips
alfredorubin96 Mar 22, 2026
8e4a745
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
3b02566
fix: aria description merge order and dependency array
alfredorubin96 Mar 24, 2026
6c2eab5
fix: normalize -1 axis label rotation sentinel to undefined
alfredorubin96 Mar 24, 2026
691453d
fix: resolve TS error, tooltip undefined seriesName, remove inert opt…
alfredorubin96 Mar 24, 2026
3d46ef3
fix: tooltip undefined seriesName, remove inert decimalPlaces options
alfredorubin96 Mar 24, 2026
675f7e9
fix: implement markLine for LineChart, add buildMarkLineFromRefs tests
alfredorubin96 Mar 24, 2026
1a29765
fix: resolve TypeScript compilation errors
alfredorubin96 Mar 24, 2026
357b181
fix: radar max fallback, seed data shape, and markdown ReDoS
alfredorubin96 Mar 24, 2026
e4fcf26
fix(e2e): use keyboard fallback when CM6 editor reports readonly
alfredorubin96 Mar 24, 2026
1c04d9c
fix(e2e): use keyboard fallback when CM6 editor reports readonly
alfredorubin96 Mar 24, 2026
33fcfe1
fix: resolve TypeScript compilation errors
alfredorubin96 Mar 24, 2026
1bfa403
Merge remote-tracking branch 'origin/feat/issue-134-datazoom' into re…
alfredorubin96 Mar 24, 2026
d59ad47
Merge remote-tracking branch 'origin/feat/issue-137-axis-labels' into…
alfredorubin96 Mar 24, 2026
9017b78
Merge remote-tracking branch 'origin/feat/issue-136-markline' into re…
alfredorubin96 Mar 24, 2026
e3448e6
feat: batch 1 — chart improvements (number format, DataZoom, axis lab…
alfredorubin96 Mar 24, 2026
8592737
Merge remote-tracking branch 'origin/fix/editor-brackets-radar-scale'…
alfredorubin96 Mar 24, 2026
0abfc99
Merge remote-tracking branch 'origin/release/a11y-and-fixes' into rel…
alfredorubin96 Mar 24, 2026
9be23f7
fix: enrich chart click point with original row data for click actions
alfredorubin96 Mar 24, 2026
beb9cbd
fix: wire missing chart props, fix E2E editor flakiness, add Chart Im…
alfredorubin96 Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions app/e2e/code-completion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,38 @@ async function triggerCypherAutocomplete(
}

/**
* Click inside the CM editor content area and type text via keyboard events.
* After typing, re-clicks the editor to ensure focus is fully settled — the
* neo4j-cypher editor's completion keymap requires a settled focus state that
* keyboard.type() alone doesn't guarantee.
* Insert exact text into the CM editor via CM6 dispatch (bypasses closeBrackets
* auto-insertion that corrupts partial Cypher like "MATCH (n:" → "MATCH (n:)").
* After dispatch, clicks the editor to ensure focus for keyboard shortcuts.
*/
async function typeInCmEditor(
dialog: Locator,
page: Page,
text: string,
): Promise<void> {
const cm = dialog.locator("[data-testid='codemirror-container'] .cm-content");
await cm.click();
await page.keyboard.type(text, { delay: 30 });
const cmContainer = dialog.locator("[data-testid='codemirror-container']");
const cm = cmContainer.locator(".cm-content");

// Use CM6 dispatch to set exact text without closeBrackets interference
await cmContainer.evaluate((el: HTMLElement, t: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findView(node: Element | null): any {
if (!node) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tile = (node as any).cmTile;
return tile?.root?.view ?? tile?.view ?? null;
}
const view = findView(el.querySelector(".cm-content")) ?? findView(el.querySelector(".cm-editor"));
if (!view) throw new Error("CM6 view not found for typeInCmEditor");
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: t },
selection: { anchor: t.length },
});
}, text);

// Re-focus: the Cypher editor's CM6 completion keymap needs a settled focus
// state after programmatic typing. Without this, Ctrl+Space may not trigger.
// state after programmatic dispatch. Without this, Ctrl+Space may not trigger.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(100);
await cm.click();
}
Expand Down
35 changes: 21 additions & 14 deletions app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,19 @@
}

// Strategy 1: Use CM6's internal dispatch API (most reliable).
// In CM6 v6.x, each DOM node managed by the editor has a `cmTile`
// property (Tile instance). The `.cm-content` element's cmTile is a
// DocTile whose `.root.view` yields the EditorView. This mirrors the
// logic of the static `EditorView.findFromDOM()` method.
// CM6 decorates managed DOM nodes with a `cmTile` property (Tile instance).
// We mirror EditorView.findFromDOM(): try .cm-content first, then .cm-editor.
const dispatched = await cmContainer.evaluate((el: HTMLElement, text: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findView(node: Element | null): any {
if (!node) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tile = (node as any).cmTile;
return tile?.root?.view ?? tile?.view ?? null;
}
const cmContent = el.querySelector(".cm-content");
if (!cmContent) return "no-editor";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tile = (cmContent as any).cmTile;
const view = tile?.root?.view ?? tile?.view;
const view = findView(cmContent) ?? findView(el.querySelector(".cm-editor"));
if (!view) return "no-view";
if (view.state.readOnly) return "readonly";

Expand All @@ -134,11 +137,14 @@
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
const stillPresent = await cmContainer.evaluate((el: HTMLElement, text: string) => {
const c = el.querySelector(".cm-content");
if (!c) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tile = (c as any).cmTile;
const view = tile?.root?.view ?? tile?.view;
function findView(node: Element | null): any {
if (!node) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tile = (node as any).cmTile;
return tile?.root?.view ?? tile?.view ?? null;
}
const view = findView(el.querySelector(".cm-content")) ?? findView(el.querySelector(".cm-editor"));
if (!view) return false;
return view.state.doc.toString().includes(text.substring(0, 20));
}, query);
Expand All @@ -148,8 +154,9 @@
return;
}

// Strategy 2: Keyboard fallback (for environments where cmView is not accessible)
if (dispatched === "no-view") {
// Strategy 2: Keyboard fallback (for environments where cmView is not accessible
// or when the view is temporarily readonly during initialization)
if (dispatched === "no-view" || dispatched === "readonly") {
await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 });
await cm.click();
await page.keyboard.press("ControlOrMeta+a");
Expand All @@ -162,9 +169,9 @@
return;
}

// Retry-worthy states: no-editor, readonly, dispatch-failed
// Retry-worthy states: no-editor, dispatch-failed
throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`);
}).toPass({ timeout: 20_000 });

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/parameters.spec.ts:1656:7 › Action rules — multi-rule editor › should show trigger column selector for table type

6) [chromium] › e2e/parameters.spec.ts:1656:7 › Action rules — multi-rule editor › should show trigger column selector for table type Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at /home/runner/work/neoboard/neoboard/app/e2e/parameters.spec.ts:1685:25

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/design-system.spec.ts:96:7 › Design system — Deep Ocean palette & accessibility › chart container has role='img' and auto-generated aria-label

5) [chromium] › e2e/design-system.spec.ts:96:7 › Design system — Deep Ocean palette & accessibility › chart container has role='img' and auto-generated aria-label Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at addBarChartWithData (/home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:40:23) at /home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:99:20

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/styling-rules.spec.ts:230:7 › Styling rules — bar chart › should enable styling for bar chart

3) [chromium] › e2e/styling-rules.spec.ts:230:7 › Styling rules — bar chart › should enable styling for bar chart Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at /home/runner/work/neoboard/neoboard/app/e2e/styling-rules.spec.ts:241:23

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/styling-rules.spec.ts:230:7 › Styling rules — bar chart › should enable styling for bar chart

3) [chromium] › e2e/styling-rules.spec.ts:230:7 › Styling rules — bar chart › should enable styling for bar chart Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at /home/runner/work/neoboard/neoboard/app/e2e/styling-rules.spec.ts:241:23

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/form-widget.spec.ts:332:7 › Form widget › form widget refreshes another widget on submit when configured

2) [chromium] › e2e/form-widget.spec.ts:332:7 › Form widget › form widget refreshes another widget on submit when configured Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at /home/runner/work/neoboard/neoboard/app/e2e/form-widget.spec.ts:344:23

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/form-widget.spec.ts:332:7 › Form widget › form widget refreshes another widget on submit when configured

2) [chromium] › e2e/form-widget.spec.ts:332:7 › Form widget › form widget refreshes another widget on submit when configured Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at /home/runner/work/neoboard/neoboard/app/e2e/form-widget.spec.ts:344:23

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/charts.spec.ts:1041:7 › Column mapping overlay › overlay visible on bar chart in edit mode

1) [chromium] › e2e/charts.spec.ts:1041:7 › Column mapping overlay › overlay visible on bar chart in edit mode Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at /home/runner/work/neoboard/neoboard/app/e2e/charts.spec.ts:1055:23

Check failure on line 174 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/charts.spec.ts:1041:7 › Column mapping overlay › overlay visible on bar chart in edit mode

1) [chromium] › e2e/charts.spec.ts:1041:7 › Column mapping overlay › overlay visible on bar chart in edit mode Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:174 172 | // Retry-worthy states: no-editor, dispatch-failed 173 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 174 | }).toPass({ timeout: 20_000 }); | ^ 175 | } 176 | 177 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:174:6) at /home/runner/work/neoboard/neoboard/app/e2e/charts.spec.ts:1055:23
}

/**
Expand Down
4 changes: 2 additions & 2 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 22 additions & 3 deletions app/src/components/chart-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,20 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab

const handleEChartsClick = useMemo(() => {
if (!onChartClick) return undefined;
return (e: EChartsClickEvent) =>
onChartClick({ name: e.name, value: e.value, seriesName: e.seriesName, dataIndex: e.dataIndex });
}, [onChartClick]);
return (e: EChartsClickEvent) => {
// Enrich the click point with the original data row so that
// column-name source fields (e.g. "revenue") resolve correctly
// in click action rules — not just ECharts built-in fields.
const row = Array.isArray(data) ? (data[e.dataIndex] as Record<string, unknown> | undefined) : undefined;
onChartClick({
...(row ?? {}),
name: e.name,
value: e.value,
seriesName: e.seriesName,
dataIndex: e.dataIndex,
});
};
}, [onChartClick, data]);

switch (type) {
case "bar":
Expand All @@ -141,10 +152,13 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab
xAxisLabel={settings.xAxisLabel as string | undefined}
yAxisLabel={settings.yAxisLabel as string | undefined}
showGridLines={settings.showGridLines as boolean | undefined}
axisLabelRotation={settings.axisLabelRotation as number | undefined}
referenceLines={settings.referenceLines as string | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
onClick={handleEChartsClick}
enableDataZoom={settings.enableDataZoom as boolean | undefined}
colorPalette={settings.colorPalette as string | undefined}
colorblindMode={settings.colorblindMode as boolean | undefined}
/>
Expand All @@ -163,10 +177,12 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab
stepped={settings.stepped as boolean | undefined}
showPoints={settings.showPoints as boolean | undefined}
showGridLines={settings.showGridLines as boolean | undefined}
referenceLines={settings.referenceLines as string | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
onClick={handleEChartsClick}
enableDataZoom={settings.enableDataZoom as boolean | undefined}
colorPalette={settings.colorPalette as string | undefined}
colorblindMode={settings.colorblindMode as boolean | undefined}
/>
Expand All @@ -183,6 +199,8 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab
labelPosition={settings.labelPosition as "outside" | "inside" | "center" | undefined}
showPercentage={settings.showPercentage as boolean | undefined}
sortSlices={settings.sortSlices as boolean | undefined}
topN={settings.topN as number | undefined}
donutCenterText={settings.donutCenterText as string | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
Expand All @@ -203,6 +221,7 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab
suffix={settings.suffix as string | undefined}
fontSize={settings.fontSize as "sm" | "md" | "lg" | "xl" | undefined}
numberFormat={settings.numberFormat as "plain" | "comma" | "compact" | "percent" | undefined}
decimalPlaces={settings.decimalPlaces as number | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
Expand Down
122 changes: 119 additions & 3 deletions app/src/lib/__tests__/chart-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1491,21 +1491,137 @@ describe("radar transform", () => {
expect(result.indicators[0].name).toBe("X");
});

it("auto-scales max from data when max column is missing", () => {
it("auto-scales max from data when max column is missing (single indicator)", () => {
const data = [{ indicator: "Speed", value: 80 }];
const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: unknown[] };
// 80 * 1.1 = 88, ceil → 88
expect(result.indicators[0].max).toBe(88);
});

it("handles flat tabular data without indicator column (uses column names as indicators)", () => {
it("uses global max across all indicators for relative comparison", () => {
const data = [
{ indicator: "ACTED_IN", value: 172 },
{ indicator: "PRODUCED", value: 15 },
{ indicator: "DIRECTED", value: 44 },
{ indicator: "WROTE", value: 10 },
{ indicator: "REVIEWED", value: 9 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> };
// Global max: ceil(172 * 1.1) = 190
const globalMax = Math.ceil(172 * 1.1);
expect(result.indicators).toHaveLength(5);
// All indicators should share the same max
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
// The shape should NOT be uniform — values differ significantly
const values = result.series[0].values;
expect(values[0]).toBe(172); // ACTED_IN
expect(values[4]).toBe(9); // REVIEWED
});

it("preserves explicit max column values when provided", () => {
const data = [
{ indicator: "Speed", value: 80, max: 200 },
{ indicator: "Strength", value: 40, max: 150 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
expect(result.indicators[0].max).toBe(200);
expect(result.indicators[1].max).toBe(150);
});

it("uses global max for wide-format tabular data", () => {
const data = [{ Speed: 80, Strength: 60, Agility: 90 }];
const result = transform(data) as { indicators: Array<{ name: string }>; series: Array<{ values: number[] }> };
const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> };
// Global max: ceil(90 * 1.1) = 99
const globalMax = Math.ceil(90 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
expect(result.indicators.map((i) => i.name)).toContain("Speed");
expect(result.indicators.map((i) => i.name)).toContain("Strength");
expect(result.series[0].values).toHaveLength(3);
});

it("falls back to globalMax when max column contains null/undefined/NaN", () => {
// When the max column exists but values are invalid (null/NaN/0),
// indicators should use globalMax instead of treating 0 or NaN as explicit.
const data = [
{ indicator: "Speed", value: 80, max: null },
{ indicator: "Strength", value: 60, max: undefined },
{ indicator: "Agility", value: 90, max: NaN },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
// globalMax: ceil(90 * 1.1) = 99
const globalMax = Math.ceil(90 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("falls back to globalMax when max column value is 0", () => {
const data = [
{ indicator: "Speed", value: 50, max: 0 },
{ indicator: "Strength", value: 30, max: 0 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
// 0 is not a valid explicit max (not > 0), so globalMax is used
const globalMax = Math.ceil(50 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("falls back to globalMax when max column value is negative", () => {
const data = [
{ indicator: "Speed", value: 50, max: -100 },
{ indicator: "Strength", value: 30, max: -50 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(50 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("mixes explicit and fallback max when some indicators have valid max", () => {
const data = [
{ indicator: "Speed", value: 80, max: 200 },
{ indicator: "Strength", value: 60, max: null },
{ indicator: "Agility", value: 90, max: 150 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(90 * 1.1);
// Speed and Agility have valid explicit max; Strength falls back to globalMax
expect(result.indicators[0].max).toBe(200); // Speed — explicit
expect(result.indicators[1].max).toBe(globalMax); // Strength — fallback
expect(result.indicators[2].max).toBe(150); // Agility — explicit
});

it("falls back to globalMax when max column contains non-numeric strings", () => {
const data = [
{ indicator: "Speed", value: 80, max: "not-a-number" },
{ indicator: "Strength", value: 60, max: "" },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(80 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("falls back to globalMax when max column contains Infinity", () => {
const data = [
{ indicator: "Speed", value: 80, max: Infinity },
{ indicator: "Strength", value: 60, max: -Infinity },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(80 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("transformWithMapping returns same result as transform", () => {
const data = [{ indicator: "Speed", value: 80, max: 100 }];
const result = chartRegistry.radar.transformWithMapping(data, {});
Expand Down
23 changes: 13 additions & 10 deletions app/src/lib/chart-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,21 +494,23 @@ function transformToRadarData(data: unknown): unknown {
const serName = seriesKey ? String(normalizeValue(r[seriesKey]) ?? "Default") : "Default";

if (maxKey) {
const explicitMax = Number(r[maxKey]) || 100;
if (!indicatorExplicitMax.has(indName)) indicatorExplicitMax.set(indName, explicitMax);
const explicitMax = Number(r[maxKey]);
if (Number.isFinite(explicitMax) && explicitMax > 0 && !indicatorExplicitMax.has(indName)) {
indicatorExplicitMax.set(indName, explicitMax);
}
Comment on lines 496 to +500

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the highest valid max per indicator, not the first one.

Line 498 makes the chosen explicit max depend on row order. In long-format radar data the same indicator is repeated across rows, so a later row can carry a larger valid max. Keeping the first one can mis-scale that axis for the later series. Please aggregate valid maxima per indicator (or fail when they disagree), and add a regression test for two rows with the same indicator but different valid max values.

Suggested fix
       if (maxKey) {
         const explicitMax = Number(r[maxKey]);
-        if (Number.isFinite(explicitMax) && explicitMax > 0 && !indicatorExplicitMax.has(indName)) {
-          indicatorExplicitMax.set(indName, explicitMax);
+        if (Number.isFinite(explicitMax) && explicitMax > 0) {
+          indicatorExplicitMax.set(
+            indName,
+            Math.max(indicatorExplicitMax.get(indName) ?? 0, explicitMax),
+          );
         }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (maxKey) {
const explicitMax = Number(r[maxKey]) || 100;
if (!indicatorExplicitMax.has(indName)) indicatorExplicitMax.set(indName, explicitMax);
const explicitMax = Number(r[maxKey]);
if (Number.isFinite(explicitMax) && explicitMax > 0 && !indicatorExplicitMax.has(indName)) {
indicatorExplicitMax.set(indName, explicitMax);
}
if (maxKey) {
const explicitMax = Number(r[maxKey]);
if (Number.isFinite(explicitMax) && explicitMax > 0) {
indicatorExplicitMax.set(
indName,
Math.max(indicatorExplicitMax.get(indName) ?? 0, explicitMax),
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/chart-registry.ts` around lines 496 - 500, The current logic that
sets indicatorExplicitMax in the block inside chart-registry.ts only keeps the
first valid explicitMax per indName, causing later larger maxima to be ignored;
change the behavior in the code that reads maxKey/explicitMax (the block that
calls Number(r[maxKey]) and indicatorExplicitMax.set(indName, explicitMax)) to
compute and store the highest valid positive finite explicitMax per indName
(e.g., if an entry already exists use Math.max(existing, explicitMax)); preserve
the validation (Number.isFinite && > 0) and do not overwrite with invalid
values, and add a regression test that feeds two rows with the same indicator
and different valid max values to assert the stored explicit max is the larger
one (or alternately fail on disagreement if that policy is preferred).

}
indicatorMaxFromData.set(indName, Math.max(indicatorMaxFromData.get(indName) ?? 0, val));
if (!seriesMap.has(serName)) seriesMap.set(serName, new Map());
seriesMap.get(serName)!.set(indName, val);
}

// Use explicit max if provided, otherwise auto-scale from observed values (+10% headroom)
// Use explicit max if provided, otherwise use a single global max across all
// indicators so relative magnitudes are visible (e.g. 172 vs 9).
const indicatorEntries = Array.from(indicatorMaxFromData.keys());
const globalMax = Math.ceil(Math.max(...indicatorMaxFromData.values()) * 1.1) || 100;
const indicators = indicatorEntries.map((name) => ({
name,
max: maxKey && indicatorExplicitMax.has(name)
? indicatorExplicitMax.get(name)!
: Math.ceil((indicatorMaxFromData.get(name) ?? 100) * 1.1) || 100,
max: indicatorExplicitMax.get(name) ?? globalMax,
}));
const series = Array.from(seriesMap.entries()).map(([name, valMap]) => ({
name,
Expand All @@ -519,17 +521,18 @@ function transformToRadarData(data: unknown): unknown {
}

// Wide-format: each column is an indicator, each row is a series
// Auto-scale max from observed values per column (+10% headroom)
const maxPerCol = new Map<string, number>();
// Use a single global max so all axes share the same scale
let wideGlobalMax = 0;
for (const r of records) {
for (const k of keys) {
const v = Number(r[k]) || 0;
maxPerCol.set(k, Math.max(maxPerCol.get(k) ?? 0, v));
if (v > wideGlobalMax) wideGlobalMax = v;
}
}
const wideMax = Math.ceil(wideGlobalMax * 1.1) || 100;
const indicators = keys.map((k) => ({
name: k,
max: Math.ceil((maxPerCol.get(k) ?? 100) * 1.1) || 100,
max: wideMax,
}));
const series = records.map((r, i) => ({
name: String(i + 1),
Expand Down
Loading
Loading