FN-7286: add productivity duration trends
Expose completed-task duration trends in Command Center productivity analytics. - Add daily average and median active-duration buckets to productivity analytics. - Render a duration-over-time line chart when qualifying completed-task history exists. - Extend chart formatting and tests for duration labels, empty trend states, and analytics aggregation. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7286-duration-trend.md | 7 +++ .../src/__tests__/productivity-analytics.test.ts | 50 +++++++++++++++++++++ packages/core/src/productivity-analytics.ts | 39 +++++++++++++++- .../command-center/areas/ProductivityArea.tsx | 52 +++++++++++++++++++++- .../areas/__tests__/areas.test-harness.tsx | 16 +++++++ .../command-center/areas/__tests__/areas.test.tsx | 46 +++++++++++++++++++ .../command-center/charts/recharts/LineChart.tsx | 21 +++++++-- 7 files changed, 225 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7286 Fusion-Task-Lineage: f9c486d0-dd8c-4736-9e8a-e658b7faecc1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7286-duration-trend.md
Normal file
7
.changeset/fn-7286-duration-trend.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add Command Center task-duration trend lines for average and median completed active time.
|
||||
category: feature
|
||||
dev: Extends productivity analytics with taskDurationTrend buckets sourced from completed task cumulativeActiveMs.
|
||||
@@ -170,6 +170,54 @@ describe("productivity-analytics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets completed-task duration trend by executionCompletedAt day with average and median", () => {
|
||||
insertCompletedTask(db, "day2-short", { cumulativeActiveMs: 30 * 60 * 1000, executionCompletedAt: "2026-03-02T09:00:00.000Z" });
|
||||
insertCompletedTask(db, "day1-short", { cumulativeActiveMs: 30 * 60 * 1000, executionCompletedAt: "2026-03-01T09:00:00.000Z" });
|
||||
insertCompletedTask(db, "day1-mid", { cumulativeActiveMs: 60 * 60 * 1000, executionCompletedAt: "2026-03-01T10:00:00.000Z" });
|
||||
insertCompletedTask(db, "day1-long", { cumulativeActiveMs: 120 * 60 * 1000, executionCompletedAt: "2026-03-01T11:00:00.000Z" });
|
||||
insertCompletedTask(db, "day2-long", { cumulativeActiveMs: 90 * 60 * 1000, executionCompletedAt: "2026-03-02T12:00:00.000Z" });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
|
||||
expect(result.taskDurationTrend).toEqual([
|
||||
{
|
||||
bucket: "2026-03-01",
|
||||
completedTasks: 3,
|
||||
averageMs: 70 * 60 * 1000,
|
||||
medianMs: 60 * 60 * 1000,
|
||||
unavailable: false,
|
||||
},
|
||||
{
|
||||
bucket: "2026-03-02",
|
||||
completedTasks: 2,
|
||||
averageMs: 60 * 60 * 1000,
|
||||
medianMs: 60 * 60 * 1000,
|
||||
unavailable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes non-qualifying tasks from completed-task duration trend without fake zero buckets", () => {
|
||||
insertCompletedTask(db, "before", { cumulativeActiveMs: 10_000, executionCompletedAt: "2026-02-28T23:59:59.999Z" });
|
||||
insertCompletedTask(db, "todo", { cumulativeActiveMs: 20_000, executionCompletedAt: "2026-03-01T00:00:00.000Z", column: "todo" });
|
||||
insertCompletedTask(db, "null-duration", { cumulativeActiveMs: null, executionCompletedAt: "2026-03-02T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "zero-duration", { cumulativeActiveMs: 0, executionCompletedAt: "2026-03-03T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "valid", { cumulativeActiveMs: 45_000, executionCompletedAt: "2026-03-04T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "after", { cumulativeActiveMs: 30_000, executionCompletedAt: "2026-04-01T00:00:00.000Z" });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
|
||||
expect(result.taskDurationTrend).toEqual([
|
||||
{
|
||||
bucket: "2026-03-04",
|
||||
completedTasks: 1,
|
||||
averageMs: 45_000,
|
||||
medianMs: 45_000,
|
||||
unavailable: false,
|
||||
},
|
||||
]);
|
||||
expect(result.taskDurationTrend).not.toContainEqual(expect.objectContaining({ averageMs: 0 }));
|
||||
expect(result.taskDurationTrend).not.toContainEqual(expect.objectContaining({ medianMs: 0 }));
|
||||
});
|
||||
|
||||
it("excludes completed-task durations outside the executionCompletedAt range", () => {
|
||||
insertCompletedTask(db, "before", { cumulativeActiveMs: 9_000, executionCompletedAt: "2026-02-28T23:59:59.999Z" });
|
||||
insertCompletedTask(db, "inside", { cumulativeActiveMs: 2_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
|
||||
@@ -220,6 +268,7 @@ describe("productivity-analytics", () => {
|
||||
expect(result.taskDuration.medianMs).not.toBe(0);
|
||||
expect(result.taskDuration.p90Ms).not.toBe(0);
|
||||
expect(result.taskDuration.totalMs).not.toBe(0);
|
||||
expect(result.taskDurationTrend).toEqual([]);
|
||||
});
|
||||
|
||||
it("empty range returns zeroed structures, not nulls", () => {
|
||||
@@ -246,6 +295,7 @@ describe("productivity-analytics", () => {
|
||||
unavailable: true,
|
||||
});
|
||||
expect(result.taskDuration.totalMs).not.toBe(0);
|
||||
expect(result.taskDurationTrend).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes a boundary task exactly at `from`", () => {
|
||||
|
||||
@@ -73,6 +73,18 @@ export interface TaskDurationSummary {
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:CommandCenterProductivity 2026-06-30-10:17:
|
||||
* Operators need average and median task active duration over time from real completed-task `cumulativeActiveMs` history. Trend buckets are emitted only for days with qualifying completed tasks; missing history must stay absent/unavailable, never fabricated as zero-duration chart points.
|
||||
*/
|
||||
export interface TaskDurationTrendBucket {
|
||||
bucket: string;
|
||||
completedTasks: number;
|
||||
averageMs: number | null;
|
||||
medianMs: number | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export interface ProductivityAnalytics {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
@@ -90,6 +102,8 @@ export interface ProductivityAnalytics {
|
||||
hoursSaved: HoursSavedSummary;
|
||||
/** Active execution duration for done tasks completed in range. */
|
||||
taskDuration: TaskDurationSummary;
|
||||
/** Per-day active execution duration for done tasks completed in range. */
|
||||
taskDurationTrend: TaskDurationTrendBucket[];
|
||||
}
|
||||
|
||||
interface CountRow {
|
||||
@@ -109,6 +123,7 @@ interface ModifiedFilesRow {
|
||||
|
||||
interface TaskDurationRow {
|
||||
cumulativeActiveMs: number;
|
||||
executionCompletedAt: string;
|
||||
}
|
||||
|
||||
/** Extract a coarse language key from a file path (its lowercased extension). */
|
||||
@@ -234,10 +249,10 @@ export function aggregateProductivityAnalytics(
|
||||
}
|
||||
const durationRows = db
|
||||
.prepare(
|
||||
`SELECT cumulativeActiveMs FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY cumulativeActiveMs ASC`,
|
||||
`SELECT cumulativeActiveMs, executionCompletedAt FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY executionCompletedAt ASC`,
|
||||
)
|
||||
.all(...durationParams) as TaskDurationRow[];
|
||||
const durations = durationRows.map((row) => row.cumulativeActiveMs);
|
||||
const durations = durationRows.map((row) => row.cumulativeActiveMs).sort((a, b) => a - b);
|
||||
const totalDurationMs = durations.reduce((sum, durationMs) => sum + durationMs, 0);
|
||||
const taskDuration: TaskDurationSummary = durations.length > 0
|
||||
? {
|
||||
@@ -257,6 +272,25 @@ export function aggregateProductivityAnalytics(
|
||||
unavailable: true,
|
||||
};
|
||||
|
||||
const durationBuckets = new Map<string, number[]>();
|
||||
for (const row of durationRows) {
|
||||
const bucket = row.executionCompletedAt.slice(0, 10);
|
||||
const bucketDurations = durationBuckets.get(bucket) ?? [];
|
||||
bucketDurations.push(row.cumulativeActiveMs);
|
||||
durationBuckets.set(bucket, bucketDurations);
|
||||
}
|
||||
const taskDurationTrend: TaskDurationTrendBucket[] = [...durationBuckets.entries()].map(([bucket, bucketDurations]) => {
|
||||
const sortedBucketDurations = [...bucketDurations].sort((a, b) => a - b);
|
||||
const bucketTotalMs = sortedBucketDurations.reduce((sum, durationMs) => sum + durationMs, 0);
|
||||
return {
|
||||
bucket,
|
||||
completedTasks: sortedBucketDurations.length,
|
||||
averageMs: sortedBucketDurations.length > 0 ? bucketTotalMs / sortedBucketDurations.length : null,
|
||||
medianMs: median(sortedBucketDurations),
|
||||
unavailable: sortedBucketDurations.length === 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
|
||||
// convert the ISO bounds to epoch ms for comparison.
|
||||
const prClauses: string[] = [];
|
||||
@@ -286,5 +320,6 @@ export function aggregateProductivityAnalytics(
|
||||
loc,
|
||||
hoursSaved,
|
||||
taskDuration,
|
||||
taskDurationTrend,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useConfirm } from "../../../hooks/useConfirm";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
import { Bar } from "../charts/Bar";
|
||||
import { PieChart } from "../charts/recharts";
|
||||
import { LineChart, PieChart } from "../charts/recharts";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||
import { formatCount, formatDurationMs } from "./areaShared";
|
||||
@@ -133,6 +133,42 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
};
|
||||
const taskDurationTrend = useMemo(
|
||||
() =>
|
||||
(data?.taskDurationTrend ?? [])
|
||||
.filter(
|
||||
(bucket) =>
|
||||
!bucket.unavailable
|
||||
&& bucket.completedTasks > 0
|
||||
&& bucket.averageMs !== null
|
||||
&& bucket.medianMs !== null
|
||||
&& Number.isFinite(bucket.averageMs)
|
||||
&& Number.isFinite(bucket.medianMs),
|
||||
),
|
||||
[data?.taskDurationTrend],
|
||||
);
|
||||
const durationTrendSeries = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("commandCenter.productivity.averageDuration", "Average"),
|
||||
values: taskDurationTrend.map((bucket) => bucket.averageMs ?? 0),
|
||||
},
|
||||
{
|
||||
label: t("commandCenter.productivity.medianDuration", "Median"),
|
||||
values: taskDurationTrend.map((bucket) => bucket.medianMs ?? 0),
|
||||
},
|
||||
],
|
||||
[t, taskDurationTrend],
|
||||
);
|
||||
const durationTrendLabels = useMemo(
|
||||
() => taskDurationTrend.map((bucket) => bucket.bucket),
|
||||
[taskDurationTrend],
|
||||
);
|
||||
const hasDurationTrend = taskDurationTrend.length > 0;
|
||||
/*
|
||||
FNXC:CommandCenterProductivity 2026-06-30-10:32:
|
||||
Operators need an average/median task-duration line graph sourced only from completed-task active execution history. Hide the graph for loading, error, legacy, empty, and no-trend payloads rather than rendering an empty shell or converting missing buckets to zero.
|
||||
*/
|
||||
/*
|
||||
FNXC:CommandCenterProductivity 2026-06-22-00:32:
|
||||
Backfill-era dashboard tests and cached clients can render ProductivityArea with legacy productivity payloads that predate LOC and hours-saved summaries. Treat missing nested summaries as unavailable sentinels so the whole Command Center remains mounted instead of crashing during responsive-layout verification.
|
||||
@@ -339,6 +375,20 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDurationTrend ? (
|
||||
<div className="cc-area-section" data-testid="cc-productivity-duration-trend">
|
||||
<h3 className="cc-area-section-title">
|
||||
{t("commandCenter.productivity.durationTrendTitle", "Task duration over time")}
|
||||
</h3>
|
||||
<LineChart
|
||||
series={durationTrendSeries}
|
||||
ariaLabel={t("commandCenter.productivity.durationTrendTitle", "Task duration over time")}
|
||||
xAxisLabels={durationTrendLabels}
|
||||
valueFormatter={formatDurationMs}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">
|
||||
{t("commandCenter.productivity.byLanguage", "Files by language")}
|
||||
|
||||
@@ -112,6 +112,22 @@ export function productivityFixture() {
|
||||
totalMs: 270 * 60 * 1000,
|
||||
unavailable: false,
|
||||
},
|
||||
taskDurationTrend: [
|
||||
{
|
||||
bucket: "2026-06-08",
|
||||
completedTasks: 1,
|
||||
averageMs: 60 * 60 * 1000,
|
||||
medianMs: 60 * 60 * 1000,
|
||||
unavailable: false,
|
||||
},
|
||||
{
|
||||
bucket: "2026-06-09",
|
||||
completedTasks: 2,
|
||||
averageMs: 105 * 60 * 1000,
|
||||
medianMs: 105 * 60 * 1000,
|
||||
unavailable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -847,6 +847,22 @@ describe("ProductivityArea", () => {
|
||||
totalMs: 16_200_000,
|
||||
unavailable: false,
|
||||
},
|
||||
taskDurationTrend: [
|
||||
{
|
||||
bucket: "2026-06-08",
|
||||
completedTasks: 1,
|
||||
averageMs: 3_600_000,
|
||||
medianMs: 3_600_000,
|
||||
unavailable: false,
|
||||
},
|
||||
{
|
||||
bucket: "2026-06-09",
|
||||
completedTasks: 2,
|
||||
averageMs: 6_300_000,
|
||||
medianMs: 6_300_000,
|
||||
unavailable: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ProductivityArea range={range7d} />);
|
||||
await screen.findByTestId("cc-area-productivity");
|
||||
@@ -864,6 +880,13 @@ describe("ProductivityArea", () => {
|
||||
expect(screen.getByTestId("cc-productivity-duration-median").textContent).toContain("1h");
|
||||
expect(screen.getByTestId("cc-productivity-duration-p90").textContent).toContain("2h");
|
||||
expect(screen.getByTestId("cc-productivity-duration-total").textContent).toContain("4h 30m");
|
||||
const durationTrend = screen.getByRole("img", { name: "Task duration over time" });
|
||||
expect(durationTrend).toBeTruthy();
|
||||
expect(durationTrend.getAttribute("data-responsive-width")).toBe("100%");
|
||||
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).toContain("Average");
|
||||
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).toContain("Median");
|
||||
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).not.toContain("NaN");
|
||||
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).not.toContain("Infinity");
|
||||
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Language share" })).toBeTruthy();
|
||||
@@ -888,12 +911,14 @@ describe("ProductivityArea", () => {
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
taskDurationTrend: [],
|
||||
});
|
||||
const { unmount } = render(<ProductivityArea range={range7d} />);
|
||||
await screen.findByTestId("cc-area-productivity-empty");
|
||||
expect(screen.queryByRole("list", { name: "Files by language" })).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
|
||||
unmount();
|
||||
|
||||
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
@@ -907,6 +932,7 @@ describe("ProductivityArea", () => {
|
||||
expect(screen.getByTestId("cc-area-productivity-error").textContent).toContain("productivity failed");
|
||||
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders unavailable task duration as dash sentinels, never zero", async () => {
|
||||
@@ -927,6 +953,22 @@ describe("ProductivityArea", () => {
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
taskDurationTrend: [
|
||||
{
|
||||
bucket: "2026-06-08",
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
{
|
||||
bucket: "2026-06-09",
|
||||
completedTasks: 1,
|
||||
averageMs: Number.NaN,
|
||||
medianMs: Number.POSITIVE_INFINITY,
|
||||
unavailable: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ProductivityArea range={range7d} />);
|
||||
@@ -939,6 +981,9 @@ describe("ProductivityArea", () => {
|
||||
expect(screen.getByTestId("cc-productivity-duration-p90-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-productivity-duration-total-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-productivity-duration-avg").textContent).not.toContain("0");
|
||||
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
|
||||
expect(screen.getByTestId("cc-area-productivity").textContent).not.toContain("NaN");
|
||||
expect(screen.getByTestId("cc-area-productivity").textContent).not.toContain("Infinity");
|
||||
});
|
||||
|
||||
it("renders dash sentinels for contract-incomplete productivity payloads", async () => {
|
||||
@@ -960,6 +1005,7 @@ describe("ProductivityArea", () => {
|
||||
expect(screen.getByTestId("cc-productivity-duration-median-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-productivity-duration-p90-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-productivity-duration-total-unavailable").textContent).toBe("—");
|
||||
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
|
||||
expect(area.textContent).not.toContain("NaN");
|
||||
expect(area.textContent?.trim()).not.toBe("");
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface LineChartProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
emptyLabel?: string;
|
||||
xAxisLabels?: string[];
|
||||
valueFormatter?: (value: number) => string;
|
||||
/**
|
||||
* `shared` keeps comparable values on one absolute axis. `series` normalizes each series to its own max so mixed-unit trends remain legible.
|
||||
*/
|
||||
@@ -183,13 +185,24 @@ function useMeasuredChartDimensions() {
|
||||
*
|
||||
* FNXC:CommandCenterCharts 2026-06-23-08:47:
|
||||
* Daily activity line and token/model line graphs must load even when Recharts cannot resolve a percentage `ResponsiveContainer` during the card's first layout pass. Measure the chart wrapper directly and pass concrete usable dimensions into Recharts, with a first-paint fallback that is replaced by ResizeObserver only after the observed box is large enough to draw a legible chart.
|
||||
*
|
||||
* FNXC:CommandCenterProductivity 2026-06-30-10:25:
|
||||
* Duration trend callers need the shared line chart to keep real millisecond values while labeling axes/tooltips as human durations and dates. Formatting stays opt-in so existing Command Center charts preserve their numeric legends and normalized mixed-unit behavior.
|
||||
*/
|
||||
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", scaleMode = "shared" }: LineChartProps) {
|
||||
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", xAxisLabels, valueFormatter, scaleMode = "shared" }: LineChartProps) {
|
||||
const theme = getCommandCenterChartTheme();
|
||||
const { ref, dimensions } = useMeasuredChartDimensions();
|
||||
const chartDimensions = resolvedDimensions(dimensions, width, height);
|
||||
const chartSeries = sanitizeSeries(series, scaleMode);
|
||||
const chartData = lineChartData(chartSeries);
|
||||
const formatXAxisLabel = (value: unknown) => {
|
||||
const index = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(index) ? (xAxisLabels?.[index - 1] ?? String(value)) : String(value);
|
||||
};
|
||||
const formatChartValue = (value: unknown) => {
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
return valueFormatter && Number.isFinite(numericValue) ? valueFormatter(numericValue) : String(value);
|
||||
};
|
||||
|
||||
if (chartSeries.length === 0 || chartData.length === 0) {
|
||||
return (
|
||||
@@ -212,12 +225,12 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c
|
||||
>
|
||||
<RechartsLineChart width={chartDimensions.width} height={chartDimensions.height} data={chartData}>
|
||||
<CartesianGrid stroke={theme.grid} />
|
||||
<XAxis dataKey="index" stroke={theme.tick} tick={{ fill: theme.tick }} />
|
||||
<XAxis dataKey="index" stroke={theme.tick} tick={{ fill: theme.tick }} tickFormatter={xAxisLabels ? formatXAxisLabel : undefined} />
|
||||
<YAxis
|
||||
stroke={theme.tick}
|
||||
tick={{ fill: theme.tick }}
|
||||
domain={scaleMode === "series" ? [0, 100] : undefined}
|
||||
tickFormatter={scaleMode === "series" ? (value) => `${value}%` : undefined}
|
||||
tickFormatter={scaleMode === "series" ? (value) => `${value}%` : valueFormatter}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
@@ -227,6 +240,8 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c
|
||||
}}
|
||||
itemStyle={{ color: theme.tooltipText }}
|
||||
labelStyle={{ color: theme.tooltipText }}
|
||||
formatter={valueFormatter ? (value) => formatChartValue(value) : undefined}
|
||||
labelFormatter={xAxisLabels ? formatXAxisLabel : undefined}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: theme.legendText }} />
|
||||
{chartSeries.map((entry, index) => (
|
||||
|
||||
Reference in New Issue
Block a user