Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions component/src/charts/__tests__/base-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ vi.mock("echarts/components", () => ({
DataZoomComponent: vi.fn(),
AriaComponent: vi.fn(),
RadarComponent: vi.fn(),
MarkLineComponent: vi.fn(),
GraphicComponent: vi.fn(),
}));

describe("BaseChart", () => {
Expand Down
109 changes: 109 additions & 0 deletions component/src/charts/__tests__/format-number.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { formatNumber, buildTooltipFormatter } from "../chart-utils";

describe("formatNumber", () => {
it("returns plain number by default", () => {
expect(formatNumber(1234)).toBe("1234");
});

it("respects decimalPlaces", () => {
expect(formatNumber(3.14159, { decimalPlaces: 2 })).toBe("3.14");
});

it("pads with zeros when decimalPlaces exceeds precision", () => {
expect(formatNumber(5, { decimalPlaces: 2 })).toBe("5.00");
});

it("applies comma formatting", () => {
expect(formatNumber(1234567, { numberFormat: "comma" })).toBe("1,234,567");
});

it("applies comma formatting with decimalPlaces", () => {
expect(formatNumber(1234567.891, { numberFormat: "comma", decimalPlaces: 2 })).toBe("1,234,567.89");
});

it("applies compact notation", () => {
const result = formatNumber(1500000, { numberFormat: "compact" });
expect(result).toMatch(/1\.5M/i);
});

it("applies compact notation with decimalPlaces", () => {
const result = formatNumber(1234, { numberFormat: "compact", decimalPlaces: 1 });
expect(result).toMatch(/1\.2K/i);
});

it("applies percent format", () => {
expect(formatNumber(75, { numberFormat: "percent" })).toBe("75%");
});

it("applies percent format with decimalPlaces", () => {
expect(formatNumber(75.678, { numberFormat: "percent", decimalPlaces: 1 })).toBe("75.7%");
});

it("adds prefix", () => {
expect(formatNumber(100, { prefix: "$" })).toBe("$100");
});

it("adds suffix", () => {
expect(formatNumber(100, { suffix: " items" })).toBe("100 items");
});

it("combines prefix, suffix, decimalPlaces, and comma", () => {
expect(formatNumber(9876.5, { prefix: "$", suffix: "M", numberFormat: "comma", decimalPlaces: 1 })).toBe("$9,876.5M");
});

it("handles zero", () => {
expect(formatNumber(0, { decimalPlaces: 2 })).toBe("0.00");
});

it("handles negative numbers", () => {
expect(formatNumber(-42.567, { decimalPlaces: 1 })).toBe("-42.6");
});

it("returns string values unchanged", () => {
expect(formatNumber("N/A" as unknown as number)).toBe("N/A");
});
});

describe("buildTooltipFormatter", () => {
it("returns a function", () => {
const formatter = buildTooltipFormatter({});
expect(typeof formatter).toBe("function");
});

it("formats a single value with config", () => {
const formatter = buildTooltipFormatter({ decimalPlaces: 1, prefix: "$" });
// ECharts tooltip params shape for axis trigger
const result = formatter({
seriesName: "Revenue",
value: 1234.56,
name: "Jan",
marker: '<span style="color:#3b82f6">●</span>',
});
expect(result).toContain("$1,234.6");
expect(result).toContain("Revenue");
});

it("handles array params (axis trigger with multiple series)", () => {
const formatter = buildTooltipFormatter({ decimalPlaces: 0 });
const result = formatter([
{ seriesName: "A", value: 100.7, name: "Jan", marker: "●" },
{ seriesName: "B", value: 200.3, name: "Jan", marker: "●" },
]);
expect(result).toContain("101");
expect(result).toContain("200");
});

it("omits seriesName label when seriesName is undefined", () => {
const formatter = buildTooltipFormatter({});
const result = formatter({ value: 42, name: "Jan" });
expect(result).not.toContain("undefined");
expect(result).toContain("<b>");
});

it("omits seriesName label when seriesName is empty string", () => {
const formatter = buildTooltipFormatter({});
const result = formatter({ seriesName: "", value: 42, name: "Jan" });
expect(result).not.toContain(": <b>");
});
});
22 changes: 22 additions & 0 deletions component/src/charts/__tests__/single-value-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ describe("SingleValueChart", () => {
expect(container.querySelector("[style]")).not.toBeInTheDocument();
});

// --- decimalPlaces ---

it("formats value with decimalPlaces", () => {
render(<SingleValueChart value={3.14159} decimalPlaces={2} />);
expect(screen.getByText("3.14")).toBeInTheDocument();
});

it("pads with zeros when decimalPlaces exceeds precision", () => {
render(<SingleValueChart value={5} decimalPlaces={2} />);
expect(screen.getByText("5.00")).toBeInTheDocument();
});

it("combines decimalPlaces with numberFormat comma", () => {
render(<SingleValueChart value={1234567.891} decimalPlaces={1} numberFormat="comma" />);
expect(screen.getByText("1,234,567.9")).toBeInTheDocument();
});

it("ignores decimalPlaces of -1 (automatic)", () => {
render(<SingleValueChart value={3.14159} decimalPlaces={-1} />);
expect(screen.getByText("3.14159")).toBeInTheDocument();
});

it("handles invalid JSON in colorThresholds gracefully", () => {
expect(() =>
render(<SingleValueChart value={10} colorThresholds="not-json" />),
Expand Down
4 changes: 4 additions & 0 deletions component/src/charts/base-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
DataZoomComponent,
AriaComponent,
RadarComponent,
MarkLineComponent,
GraphicComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { EChartsOption } from "echarts";
Expand All @@ -35,6 +37,8 @@ echarts.use([
DataZoomComponent,
AriaComponent,
RadarComponent,
MarkLineComponent,
GraphicComponent,
CanvasRenderer,
]);

Expand Down
82 changes: 82 additions & 0 deletions component/src/charts/chart-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,88 @@
import type { StylingRule } from "./styling-rule";
import { resolveStylingRuleColor } from "./styling-rule";

// ---------------------------------------------------------------------------
// Number formatting
// ---------------------------------------------------------------------------

export type NumberFormat = "plain" | "comma" | "compact" | "percent";

export interface NumberFormatConfig {
numberFormat?: NumberFormat;
decimalPlaces?: number;
prefix?: string;
suffix?: string;
}

/**
* Format a numeric value with optional decimal places, locale formatting,
* compact notation, prefix, and suffix. Non-numeric values pass through as-is.
*/
export function formatNumber(value: number | string, config: NumberFormatConfig = {}): string {
if (typeof value !== "number" || !Number.isFinite(value)) return String(value);

const { numberFormat = "plain", decimalPlaces, prefix = "", suffix = "" } = config;

let formatted: string;

switch (numberFormat) {
case "comma":
formatted = decimalPlaces !== undefined

Check warning on line 33 in component/src/charts/chart-utils.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0WOYXI4T-oZG71fyRC&open=AZ0WOYXI4T-oZG71fyRC&pullRequest=169
? value.toLocaleString("en-US", { minimumFractionDigits: decimalPlaces, maximumFractionDigits: decimalPlaces })
: value.toLocaleString("en-US");
break;
case "compact":
formatted = Intl.NumberFormat("en", {
notation: "compact",
...(decimalPlaces !== undefined ? { minimumFractionDigits: decimalPlaces, maximumFractionDigits: decimalPlaces } : {}),

Check warning on line 40 in component/src/charts/chart-utils.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0WOYXI4T-oZG71fyRD&open=AZ0WOYXI4T-oZG71fyRD&pullRequest=169
}).format(value);
break;
case "percent":
formatted = decimalPlaces !== undefined

Check warning on line 44 in component/src/charts/chart-utils.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0WOYXI4T-oZG71fyRE&open=AZ0WOYXI4T-oZG71fyRE&pullRequest=169
? `${value.toFixed(decimalPlaces)}%`
: `${value}%`;
break;
default: // "plain"
formatted = decimalPlaces !== undefined ? value.toFixed(decimalPlaces) : String(value);

Check warning on line 49 in component/src/charts/chart-utils.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0WOYXI4T-oZG71fyRF&open=AZ0WOYXI4T-oZG71fyRF&pullRequest=169
break;
}

return `${prefix}${formatted}${suffix}`;
}

// ---------------------------------------------------------------------------
// ECharts tooltip formatter
// ---------------------------------------------------------------------------

interface TooltipParam {
seriesName?: string;
name?: string;
value?: number | string | (number | string)[];
marker?: string;
}

/**
* Build an ECharts tooltip formatter function that applies consistent number
* formatting across all chart types. Works with both single and array params
* (item trigger vs axis trigger).
*/
export function buildTooltipFormatter(config: NumberFormatConfig): (params: TooltipParam | TooltipParam[]) => string {
// Tooltip always uses comma format for readability unless explicitly set
const tooltipConfig: NumberFormatConfig = { numberFormat: "comma", ...config };

return (params: TooltipParam | TooltipParam[]) => {
const items = Array.isArray(params) ? params : [params];
const header = items[0]?.name ?? "";
const lines = items.map((p) => {
const raw = Array.isArray(p.value) ? p.value[1] : p.value;
const val = typeof raw === "number" ? formatNumber(raw, tooltipConfig) : String(raw ?? "");
const label = p.seriesName ? `${p.seriesName}: ` : "";
return `${p.marker ?? ""} ${label}<b>${val}</b>`;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return header ? `${header}<br/>${lines.join("<br/>")}` : lines.join("<br/>");
};
}

/** Detect whether the document is currently in dark mode. */
export function isDark(): boolean {
if (typeof document === "undefined") return false;
Expand Down
26 changes: 8 additions & 18 deletions component/src/charts/single-value-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,12 @@ import { cn } from "@/lib/utils";
import { parseColorThresholds, resolveThresholdColor } from "./color-threshold";
import type { StylingRule } from "./styling-rule";
import { resolveStylingRuleColor } from "./styling-rule";
import { formatNumber } from "./chart-utils";
import type { NumberFormat } from "./chart-utils";

export type { ColorThreshold } from "./color-threshold";
export type SingleValueFontSize = "sm" | "md" | "lg" | "xl";
export type SingleValueNumberFormat = "plain" | "comma" | "compact" | "percent";

/** Format a numeric value according to the chosen format. */
function applyNumberFormat(numericValue: number, fmt: SingleValueNumberFormat): string {
switch (fmt) {
case "comma":
return numericValue.toLocaleString();
case "compact":
return Intl.NumberFormat("en", { notation: "compact" }).format(numericValue);
case "percent":
return `${numericValue}%`;
default:
return String(numericValue);
}
}
export type SingleValueNumberFormat = NumberFormat;

const FONT_SIZE_CLASS: Record<SingleValueFontSize, string> = {
sm: "text-xl",
Expand All @@ -46,6 +34,8 @@ export interface SingleValueChartProps {
fontSize?: SingleValueFontSize;
/** Built-in number formatting applied when value is numeric and format is not provided */
numberFormat?: SingleValueNumberFormat;
/** Fixed decimal places (0-6). Set to -1 or omit for automatic. */
decimalPlaces?: number;
/** @deprecated Use stylingRules instead. JSON string of thresholds */
colorThresholds?: string;
/** Rule-based styling rules */
Expand Down Expand Up @@ -73,6 +63,7 @@ function SingleValueChart({
format,
fontSize = "lg",
numberFormat = "plain",
decimalPlaces,
colorThresholds,
stylingRules,
paramValues,
Expand All @@ -96,10 +87,9 @@ function SingleValueChart({
if (typeof value === "number") {
if (format) {
displayValue = format(value);
} else if (numberFormat !== "plain") {
displayValue = applyNumberFormat(value, numberFormat);
} else {
displayValue = value;
const dp = decimalPlaces !== undefined && decimalPlaces >= 0 ? decimalPlaces : undefined;
displayValue = formatNumber(value, { numberFormat, decimalPlaces: dp });
}
} else {
displayValue = value;
Expand Down
1 change: 1 addition & 0 deletions component/src/components/composed/chart-options-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const singleValueOptions: ChartOptionDef[] = [
{ key: "title", label: "Title", type: "text", default: "", category: "Display", description: "Custom heading shown above the value. Leave blank to hide." },
{ key: "prefix", label: "Prefix", type: "text", default: "", category: "Display", description: "Text prepended to the value (e.g. '$', '€')." },
{ key: "suffix", label: "Suffix", type: "text", default: "", category: "Display", description: "Text appended to the value (e.g. '%', ' items')." },
{ key: "decimalPlaces", label: "Decimal Places", type: "number", default: -1, category: "Display", description: "Fixed number of decimal places (0-6). Set to -1 for automatic." },
{
key: "fontSize",
label: "Font Size",
Expand Down
2 changes: 2 additions & 0 deletions component/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ vi.mock("echarts/components", () => ({
DataZoomComponent: vi.fn(),
AriaComponent: vi.fn(),
RadarComponent: vi.fn(),
MarkLineComponent: vi.fn(),
GraphicComponent: vi.fn(),
}));

vi.mock("echarts/renderers", () => ({
Expand Down
Loading