@carlonicora/nextjs-jsonapi 1.142.0 → 1.143.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 (102) hide show
  1. package/dist/AbstractService-B0T7h8fX.d.mts +124 -0
  2. package/dist/AbstractService-Cew0PD0L.d.ts +124 -0
  3. package/dist/{AuthComponent-DdxCFgUZ.d.mts → AuthComponent-Cik96ElA.d.mts} +1 -1
  4. package/dist/{AuthComponent-Cd7lcYif.d.ts → AuthComponent-DTTKu3VK.d.ts} +1 -1
  5. package/dist/{BlockNoteEditor-35DDJH3M.mjs → BlockNoteEditor-34QM4UR5.mjs} +4 -4
  6. package/dist/{BlockNoteEditor-XXTBZ7DD.js → BlockNoteEditor-LMKWUOJG.js} +19 -19
  7. package/dist/{BlockNoteEditor-XXTBZ7DD.js.map → BlockNoteEditor-LMKWUOJG.js.map} +1 -1
  8. package/dist/billing/index.js +357 -357
  9. package/dist/billing/index.mjs +3 -3
  10. package/dist/{chunk-KZHHKPQZ.mjs → chunk-BIL2WQEV.mjs} +247 -1
  11. package/dist/chunk-BIL2WQEV.mjs.map +1 -0
  12. package/dist/{chunk-5SFFHIEE.js → chunk-DS4IBFWA.js} +252 -6
  13. package/dist/chunk-DS4IBFWA.js.map +1 -0
  14. package/dist/{chunk-7CR4YB7Y.js → chunk-F6PZZ4KI.js} +730 -730
  15. package/dist/{chunk-7CR4YB7Y.js.map → chunk-F6PZZ4KI.js.map} +1 -1
  16. package/dist/{chunk-ZV2GZKFR.mjs → chunk-HN5ED7OF.mjs} +2 -2
  17. package/dist/{chunk-FZ5SPJEU.mjs → chunk-UGSOBBZH.mjs} +4 -4
  18. package/dist/{chunk-C6UXEPNY.js → chunk-UXJAS6C5.js} +7 -7
  19. package/dist/{chunk-C6UXEPNY.js.map → chunk-UXJAS6C5.js.map} +1 -1
  20. package/dist/client/index.d.mts +3 -3
  21. package/dist/client/index.d.ts +3 -3
  22. package/dist/client/index.js +4 -4
  23. package/dist/client/index.mjs +3 -3
  24. package/dist/components/index.d.mts +3 -3
  25. package/dist/components/index.d.ts +3 -3
  26. package/dist/components/index.js +4 -4
  27. package/dist/components/index.mjs +3 -3
  28. package/dist/{config-BrkU_Ynz.d.ts → config-Cd5xTEfZ.d.ts} +1 -1
  29. package/dist/{config-snd2ToEB.d.mts → config-YyhFHxkU.d.mts} +1 -1
  30. package/dist/contexts/index.d.mts +1 -1
  31. package/dist/contexts/index.d.ts +1 -1
  32. package/dist/contexts/index.js +4 -4
  33. package/dist/contexts/index.mjs +3 -3
  34. package/dist/core/index.d.mts +13 -49
  35. package/dist/core/index.d.ts +13 -49
  36. package/dist/core/index.js +8 -2
  37. package/dist/core/index.js.map +1 -1
  38. package/dist/core/index.mjs +7 -1
  39. package/dist/features/help/index.d.mts +2 -2
  40. package/dist/features/help/index.d.ts +2 -2
  41. package/dist/features/help/index.js +37 -37
  42. package/dist/features/help/index.mjs +3 -3
  43. package/dist/features/tokenusage/index.css +29 -0
  44. package/dist/features/tokenusage/index.css.map +1 -0
  45. package/dist/features/tokenusage/index.d.mts +388 -0
  46. package/dist/features/tokenusage/index.d.ts +388 -0
  47. package/dist/features/tokenusage/index.js +1012 -0
  48. package/dist/features/tokenusage/index.js.map +1 -0
  49. package/dist/features/tokenusage/index.mjs +1012 -0
  50. package/dist/features/tokenusage/index.mjs.map +1 -0
  51. package/dist/index.d.mts +8 -6
  52. package/dist/index.d.ts +8 -6
  53. package/dist/index.js +9 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/index.mjs +8 -2
  56. package/dist/{s3.service-Dmfxpn-k.d.ts → s3.service-CpwA3sl_.d.ts} +2 -123
  57. package/dist/{s3.service-DYflLXCt.d.mts → s3.service-Dt6MfuHr.d.mts} +2 -123
  58. package/dist/server/index.d.mts +3 -2
  59. package/dist/server/index.d.ts +3 -2
  60. package/dist/server/index.js +3 -3
  61. package/dist/server/index.mjs +1 -1
  62. package/dist/tokenusage-admin.module-5wJ_tZTj.d.ts +61 -0
  63. package/dist/tokenusage-admin.module-DQVT4O4j.d.mts +61 -0
  64. package/dist/{types-CQSjy7et.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
  65. package/dist/{types-DHOxe8rc.d.ts → types-bsm8Fp55.d.ts} +1 -1
  66. package/dist/{usePageUrlGenerator-tjq2mlDV.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
  67. package/dist/{usePageUrlGenerator-uOnyJ6j2.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
  68. package/package.json +6 -1
  69. package/src/core/index.ts +5 -0
  70. package/src/core/registry/ModuleRegistry.ts +4 -0
  71. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +189 -0
  72. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +138 -0
  73. package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +177 -0
  74. package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +160 -0
  75. package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +99 -0
  76. package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +225 -0
  77. package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +42 -0
  78. package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +41 -0
  79. package/src/features/tokenusage/components/__tests__/TokenUsageTimelineChart.spec.tsx +47 -0
  80. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +252 -0
  81. package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +80 -0
  82. package/src/features/tokenusage/data/TokenUsageAdminService.ts +54 -0
  83. package/src/features/tokenusage/data/__tests__/tokenusage-admin-models.spec.ts +147 -0
  84. package/src/features/tokenusage/data/index.ts +8 -0
  85. package/src/features/tokenusage/data/tokenusage-admin-breakdown.interface.ts +18 -0
  86. package/src/features/tokenusage/data/tokenusage-admin-breakdown.ts +115 -0
  87. package/src/features/tokenusage/data/tokenusage-admin-summary.interface.ts +14 -0
  88. package/src/features/tokenusage/data/tokenusage-admin-summary.ts +90 -0
  89. package/src/features/tokenusage/data/tokenusage-admin-timeline.interface.ts +13 -0
  90. package/src/features/tokenusage/data/tokenusage-admin-timeline.ts +94 -0
  91. package/src/features/tokenusage/data/tokenusage-admin.types.ts +56 -0
  92. package/src/features/tokenusage/i18n-keys.ts +65 -0
  93. package/src/features/tokenusage/index.ts +30 -0
  94. package/src/features/tokenusage/lib/metrics.ts +69 -0
  95. package/src/features/tokenusage/lib/operation-label.ts +41 -0
  96. package/src/features/tokenusage/lib/palette.ts +197 -0
  97. package/src/features/tokenusage/tokenusage-admin.module.ts +22 -0
  98. package/dist/chunk-5SFFHIEE.js.map +0 -1
  99. package/dist/chunk-KZHHKPQZ.mjs.map +0 -1
  100. /package/dist/{BlockNoteEditor-35DDJH3M.mjs.map → BlockNoteEditor-34QM4UR5.mjs.map} +0 -0
  101. /package/dist/{chunk-ZV2GZKFR.mjs.map → chunk-HN5ED7OF.mjs.map} +0 -0
  102. /package/dist/{chunk-FZ5SPJEU.mjs.map → chunk-UGSOBBZH.mjs.map} +0 -0
@@ -0,0 +1,225 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useTheme } from "next-themes";
5
+ import { useMemo } from "react";
6
+ import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
7
+ import {
8
+ ChartConfig,
9
+ ChartContainer,
10
+ ChartLegend,
11
+ ChartLegendContent,
12
+ ChartTooltip,
13
+ ChartTooltipContent,
14
+ } from "../../../shadcnui";
15
+ import { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface";
16
+ import { Metric, StackBy } from "../data/tokenusage-admin.types";
17
+ import { operationLabel } from "../lib/operation-label";
18
+ import { CATEGORICAL_CEILING, ChartMode, OTHER_COLOR, seriesColor } from "../lib/palette";
19
+
20
+ /** The rollup series every series past the colour ceiling folds into. */
21
+ const OTHER_SERIES = "other";
22
+
23
+ type TokenUsageTimelineChartProps = {
24
+ rows: TokenUsageAdminTimelineInterface[];
25
+ metric: Metric;
26
+ stackBy: StackBy;
27
+ className?: string;
28
+ };
29
+
30
+ type TimelineBucket = { bucket: string } & Record<string, number | string>;
31
+
32
+ /**
33
+ * The bucket key, derived with UTC getters.
34
+ *
35
+ * The backend field is `type: "date"` — a calendar day with no time — and the
36
+ * model parses the `YYYY-MM-DD` wire value with `new Date(...)`, which lands on
37
+ * UTC midnight. Reading it back with local getters would shift the bucket a day
38
+ * early west of UTC, so the key (and every label built from it) stays in UTC.
39
+ */
40
+ const bucketKey = (date: Date): string => {
41
+ const y = date.getUTCFullYear();
42
+ const m = `${date.getUTCMonth() + 1}`.padStart(2, "0");
43
+ const d = `${date.getUTCDate()}`.padStart(2, "0");
44
+ return `${y}-${m}-${d}`;
45
+ };
46
+
47
+ const metricValue = (row: TokenUsageAdminTimelineInterface, metric: Metric): number => {
48
+ if (metric === "cost") return row.cost;
49
+ if (metric === "credits") return row.credits;
50
+ return row.tokensIn + row.tokensOut;
51
+ };
52
+
53
+ /**
54
+ * The granularity the data was bucketed at, inferred from the spacing between
55
+ * consecutive buckets. The backend chooses the granularity and the rows carry
56
+ * the consequence, so the axis reads it off the data rather than taking a prop
57
+ * that could disagree with what was actually fetched.
58
+ */
59
+ const inferGranularity = (buckets: string[]): "day" | "week" | "month" => {
60
+ if (buckets.length < 2) return "day";
61
+
62
+ const dayMs = 24 * 60 * 60 * 1000;
63
+ let smallestGap = Number.POSITIVE_INFINITY;
64
+ for (let i = 1; i < buckets.length; i += 1) {
65
+ const gap = (Date.parse(buckets[i]) - Date.parse(buckets[i - 1])) / dayMs;
66
+ if (gap > 0 && gap < smallestGap) smallestGap = gap;
67
+ }
68
+
69
+ if (smallestGap >= 28) return "month";
70
+ if (smallestGap >= 7) return "week";
71
+ return "day";
72
+ };
73
+
74
+ const bucketFormatter = (granularity: "day" | "week" | "month"): Intl.DateTimeFormat =>
75
+ granularity === "month"
76
+ ? new Intl.DateTimeFormat("it-IT", { month: "short", year: "numeric", timeZone: "UTC" })
77
+ : new Intl.DateTimeFormat("it-IT", { day: "numeric", month: "short", timeZone: "UTC" });
78
+
79
+ const compactNumber = new Intl.NumberFormat("it-IT", { notation: "compact", maximumFractionDigits: 1 });
80
+
81
+ /**
82
+ * Usage over time as a stacked bar chart.
83
+ *
84
+ * Colour does an IDENTITY job here — each stacked segment is a series, not a
85
+ * magnitude — so it draws the fixed categorical order from `../lib/palette` and
86
+ * never generates or cycles a hue: series past the ceiling are summed into a
87
+ * single "other" segment painted in the palette's neutral.
88
+ *
89
+ * The palette's light-mode validator run carries a sub-3:1 contrast WARN on
90
+ * three slots, which obliges a relief channel. That is why the legend and the
91
+ * value-carrying tooltip below are not optional decoration: they are what makes
92
+ * the low-contrast fills readable.
93
+ */
94
+ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: TokenUsageTimelineChartProps) {
95
+ const t = useTranslations();
96
+ const { resolvedTheme } = useTheme();
97
+ const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light";
98
+
99
+ const { chartData, seriesKeys } = useMemo(() => {
100
+ if (rows.length === 0) return { chartData: [] as TimelineBucket[], seriesKeys: [] as string[] };
101
+
102
+ // Totals per raw series decide who keeps an identity colour: the biggest
103
+ // seven do, everything else is summed into "other". Ranking by total (not by
104
+ // first appearance) keeps the assignment stable while a filter changes which
105
+ // series are present.
106
+ const totals = new Map<string, number>();
107
+ for (const row of rows) totals.set(row.series, (totals.get(row.series) ?? 0) + metricValue(row, metric));
108
+
109
+ const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([series]) => series);
110
+ const named = ranked.slice(0, CATEGORICAL_CEILING);
111
+ const folded = new Set(ranked.slice(CATEGORICAL_CEILING));
112
+
113
+ const keys = folded.size > 0 ? [...named, OTHER_SERIES] : named;
114
+
115
+ const byBucket = new Map<string, TimelineBucket>();
116
+ for (const row of rows) {
117
+ const key = bucketKey(row.bucket);
118
+ let entry = byBucket.get(key);
119
+ if (!entry) {
120
+ entry = { bucket: key };
121
+ for (const series of keys) entry[series] = 0;
122
+ byBucket.set(key, entry);
123
+ }
124
+ const series = folded.has(row.series) ? OTHER_SERIES : row.series;
125
+ entry[series] = (entry[series] as number) + metricValue(row, metric);
126
+ }
127
+
128
+ return {
129
+ chartData: [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)),
130
+ seriesKeys: keys,
131
+ };
132
+ }, [rows, metric]);
133
+
134
+ const seriesLabel = (series: string): string => {
135
+ if (series === OTHER_SERIES) {
136
+ const key = "token_usage.series.other";
137
+ return t.has(key) ? t(key) : "Altro";
138
+ }
139
+ // Stacking by company makes the series a company NAME, which is data, not
140
+ // vocabulary — it is never looked up. Scope and type keys are vocabulary the
141
+ // consuming app supplies, with the raw key as the fallback.
142
+ if (stackBy === "company") return series;
143
+ return operationLabel(
144
+ series,
145
+ (key) => t(key),
146
+ (key) => t.has(key),
147
+ );
148
+ };
149
+
150
+ const chartConfig = useMemo(
151
+ () => Object.fromEntries(seriesKeys.map((series) => [series, { label: seriesLabel(series) }])) as ChartConfig,
152
+ [seriesKeys, stackBy],
153
+ );
154
+
155
+ if (chartData.length === 0) {
156
+ const key = "token_usage.timeline.empty";
157
+ return (
158
+ <p className={className ? `text-muted-foreground text-sm ${className}` : "text-muted-foreground text-sm"}>
159
+ {t.has(key) ? t(key) : "Nessun dato nel periodo selezionato"}
160
+ </p>
161
+ );
162
+ }
163
+
164
+ const granularity = inferGranularity(chartData.map((entry) => entry.bucket));
165
+ const formatBucket = bucketFormatter(granularity);
166
+ const seriesColorFor = (series: string, index: number) =>
167
+ series === OTHER_SERIES ? OTHER_COLOR : seriesColor(index, mode);
168
+
169
+ return (
170
+ <div className={className}>
171
+ {/* The pivot, exposed for assertions and for screen readers that would
172
+ otherwise get nothing from the SVG. */}
173
+ <span className="sr-only" data-testid="timeline-data">
174
+ {JSON.stringify(chartData)}
175
+ </span>
176
+ <span className="sr-only" data-testid="timeline-series">
177
+ {JSON.stringify(seriesKeys)}
178
+ </span>
179
+
180
+ <ChartContainer config={chartConfig} className="aspect-auto h-72 w-full">
181
+ <BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
182
+ <CartesianGrid vertical={false} />
183
+ <XAxis
184
+ dataKey="bucket"
185
+ tickLine={false}
186
+ axisLine={false}
187
+ tickMargin={8}
188
+ minTickGap={16}
189
+ tickFormatter={(value: string) => formatBucket.format(new Date(`${value}T00:00:00.000Z`))}
190
+ />
191
+ <YAxis
192
+ tickLine={false}
193
+ axisLine={false}
194
+ tickMargin={8}
195
+ width={48}
196
+ tickFormatter={(value: number) => compactNumber.format(value)}
197
+ />
198
+ <ChartTooltip
199
+ content={
200
+ <ChartTooltipContent
201
+ labelFormatter={(value) => formatBucket.format(new Date(`${String(value)}T00:00:00.000Z`))}
202
+ />
203
+ }
204
+ />
205
+ {seriesKeys.length >= 2 ? <ChartLegend content={<ChartLegendContent />} /> : null}
206
+ {seriesKeys.map((series, index) => (
207
+ <Bar
208
+ key={series}
209
+ dataKey={series}
210
+ stackId="usage"
211
+ maxBarSize={28}
212
+ fill={seriesColorFor(series, index)}
213
+ // A 2px stroke in the surface colour is the gap between stacked
214
+ // segments and between neighbouring bars — the palette's own
215
+ // secondary-encoding channel, not a border.
216
+ stroke="var(--background)"
217
+ strokeWidth={2}
218
+ radius={index === seriesKeys.length - 1 ? [4, 4, 0, 0] : 0}
219
+ />
220
+ ))}
221
+ </BarChart>
222
+ </ChartContainer>
223
+ </div>
224
+ );
225
+ }
@@ -0,0 +1,42 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { TokenUsageAdminTiles } from "../TokenUsageAdminTiles";
4
+
5
+ const row = (scope: string, window: string, cost: number, calls = 1) =>
6
+ ({ id: `${scope}|${window}`, scope, window, cost, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls }) as any;
7
+
8
+ const summary = [
9
+ row("customer", "current", 9.46, 2956),
10
+ row("platform", "current", 9.47, 7681),
11
+ row("total", "current", 18.93, 10637),
12
+ row("customer", "previous", 8.0, 2000),
13
+ row("platform", "previous", 10.0, 8000),
14
+ row("total", "previous", 18.0, 10000),
15
+ ];
16
+
17
+ describe("TokenUsageAdminTiles", () => {
18
+ it("shows both cost centres with their deltas against the previous window", () => {
19
+ render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode={false} />);
20
+
21
+ expect(screen.getByTestId("tile-customer")).toHaveTextContent("9,46");
22
+ expect(screen.getByTestId("tile-platform")).toHaveTextContent("9,47");
23
+ // customer 9.46 vs 8.00 => +18%
24
+ expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("18");
25
+ // platform 9.47 vs 10.00 => -5%
26
+ expect(screen.getByTestId("tile-platform-delta")).toHaveTextContent("5");
27
+ });
28
+
29
+ it("hides the platform tile in single-customer mode", () => {
30
+ render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode />);
31
+
32
+ expect(screen.getByTestId("tile-customer")).toBeInTheDocument();
33
+ expect(screen.queryByTestId("tile-platform")).not.toBeInTheDocument();
34
+ });
35
+
36
+ it("suppresses the delta rather than showing Infinity when the previous window is zero", () => {
37
+ const zeroPrev = summary.map((r) => (r.window === "previous" ? { ...r, cost: 0 } : r));
38
+ render(<TokenUsageAdminTiles summary={zeroPrev as any} metric="cost" singleCustomerMode={false} />);
39
+
40
+ expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("—");
41
+ });
42
+ });
@@ -0,0 +1,41 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { TokenUsageRankedBar } from "../TokenUsageRankedBar";
4
+
5
+ const row = (id: string, label: string, cost: number) =>
6
+ ({ id, label, cost, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls: 1 }) as any;
7
+
8
+ describe("TokenUsageRankedBar", () => {
9
+ it("direct-labels each row with its value and its share of the total", () => {
10
+ render(
11
+ <TokenUsageRankedBar
12
+ rows={[row("a", "Studio Rossi", 6), row("b", "Studio Bianchi", 4)]}
13
+ metric="cost"
14
+ emptyLabel="nessun dato"
15
+ />,
16
+ );
17
+
18
+ expect(screen.getByTestId("ranked-value-a")).toHaveTextContent("6,00");
19
+ expect(screen.getByTestId("ranked-share-a")).toHaveTextContent("60");
20
+ expect(screen.getByTestId("ranked-share-b")).toHaveTextContent("40");
21
+ });
22
+
23
+ it("renders the empty label when there are no rows", () => {
24
+ render(<TokenUsageRankedBar rows={[]} metric="cost" emptyLabel="nessun dato" />);
25
+ expect(screen.getByText("nessun dato")).toBeInTheDocument();
26
+ });
27
+
28
+ it("assigns colour by rank position from one ramp, so no two rows share an identity hue by accident", () => {
29
+ render(
30
+ <TokenUsageRankedBar
31
+ rows={[row("a", "A", 3), row("b", "B", 2), row("c", "C", 1)]}
32
+ metric="cost"
33
+ emptyLabel="nessun dato"
34
+ />,
35
+ );
36
+
37
+ const colours = ["a", "b", "c"].map((id) => screen.getByTestId(`ranked-fill-${id}`).getAttribute("data-ramp-step"));
38
+ expect(new Set(colours).size).toBe(3);
39
+ expect(colours).toEqual(["0", "1", "2"]);
40
+ });
41
+ });
@@ -0,0 +1,47 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { TokenUsageTimelineChart } from "../TokenUsageTimelineChart";
4
+
5
+ const row = (bucket: string, series: string, cost: number) =>
6
+ ({
7
+ id: `${bucket}|${series}`,
8
+ bucket: new Date(bucket),
9
+ series,
10
+ cost,
11
+ credits: 0,
12
+ tokensIn: 0,
13
+ tokensOut: 0,
14
+ cached: 0,
15
+ calls: 1,
16
+ }) as any;
17
+
18
+ describe("TokenUsageTimelineChart", () => {
19
+ it("pivots flat rows into one entry per bucket with a key per series", () => {
20
+ render(
21
+ <TokenUsageTimelineChart
22
+ rows={[row("2026-08-01", "customer", 1), row("2026-08-01", "platform", 2), row("2026-08-02", "customer", 3)]}
23
+ metric="cost"
24
+ stackBy="scope"
25
+ />,
26
+ );
27
+
28
+ const data = JSON.parse(screen.getByTestId("timeline-data").textContent!);
29
+ expect(data).toHaveLength(2);
30
+ expect(data[0]).toMatchObject({ customer: 1, platform: 2 });
31
+ expect(data[1]).toMatchObject({ customer: 3 });
32
+ });
33
+
34
+ it("folds series beyond the 7-colour ceiling into a single 'other' series", () => {
35
+ const rows = Array.from({ length: 10 }, (_, i) => row("2026-08-01", `type${i}`, 10 - i));
36
+ render(<TokenUsageTimelineChart rows={rows} metric="cost" stackBy="type" />);
37
+
38
+ const series = JSON.parse(screen.getByTestId("timeline-series").textContent!);
39
+ expect(series).toHaveLength(8);
40
+ expect(series[7]).toBe("other");
41
+ });
42
+
43
+ it("renders the empty state without a chart when there are no rows", () => {
44
+ render(<TokenUsageTimelineChart rows={[]} metric="cost" stackBy="scope" />);
45
+ expect(screen.queryByTestId("timeline-data")).not.toBeInTheDocument();
46
+ });
47
+ });
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
5
+ import { SharedProvider } from "../../../contexts";
6
+ import { usePageUrlGenerator } from "../../../hooks";
7
+ import { BreadcrumbItemData } from "../../../interfaces";
8
+ import { TokenUsageAdminFilterBar } from "../components/TokenUsageAdminFilterBar";
9
+ import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
10
+ import type { TokenUsageAdminSummaryInterface } from "../data/tokenusage-admin-summary.interface";
11
+ import type { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface";
12
+ import type { Granularity, Metric, StackBy } from "../data/tokenusage-admin.types";
13
+ import { TokenUsageAdminService } from "../data/TokenUsageAdminService";
14
+
15
+ /**
16
+ * Route the breadcrumb links back to. The page itself lives in the consuming
17
+ * app (a360ai mounts it at `/administration/token-usage`); the constant is here
18
+ * because the breadcrumb is built by this provider, not by the app.
19
+ */
20
+ const TOKEN_USAGE_ADMIN_PAGE_URL = "/administration/token-usage";
21
+
22
+ /** Rows kept per ranked panel before the repository folds the tail into "other". */
23
+ const DEFAULT_TOP_N = 10;
24
+
25
+ export type TokenUsageAdminFilterState = {
26
+ /** ISO 8601 instant. */
27
+ from: string;
28
+ /** ISO 8601 instant. */
29
+ to: string;
30
+ granularity: Granularity;
31
+ stackBy: StackBy;
32
+ companyId?: string;
33
+ metric: Metric;
34
+ };
35
+
36
+ export interface TokenUsageAdminContextType {
37
+ summary: TokenUsageAdminSummaryInterface[];
38
+ timeline: TokenUsageAdminTimelineInterface[];
39
+ byCompany: TokenUsageAdminBreakdownInterface[];
40
+ byUser: TokenUsageAdminBreakdownInterface[];
41
+ /** Platform-side spend split by operation. Always empty in single-customer mode. */
42
+ /** Platform spend by operation — empty in single-customer mode. */
43
+ byOperation: TokenUsageAdminBreakdownInterface[];
44
+ /** Customer spend by operation — the customer-side mirror of byOperation. */
45
+ byCustomerOperation: TokenUsageAdminBreakdownInterface[];
46
+ companies: { id: string; label: string }[];
47
+ filters: TokenUsageAdminFilterState;
48
+ /** Merges a partial patch into the current filters; every key is optional. */
49
+ setFilters: (next: Partial<TokenUsageAdminFilterState>) => void;
50
+ /** `true` once a company filter is applied — the platform panels are meaningless then. */
51
+ singleCustomerMode: boolean;
52
+ isLoading: boolean;
53
+ error: string | null;
54
+ }
55
+
56
+ const TokenUsageAdminContext = createContext<TokenUsageAdminContextType | undefined>(undefined);
57
+
58
+ /** Current calendar month to now, which is the window the page opens on. */
59
+ function defaultRange(): { from: string; to: string } {
60
+ const now = new Date();
61
+ const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
62
+ return { from: start.toISOString(), to: now.toISOString() };
63
+ }
64
+
65
+ type TokenUsageAdminProviderProps = {
66
+ children: ReactNode;
67
+ /** Pre-select a company, which puts the page in single-customer mode from the first render. */
68
+ initialCompanyId?: string;
69
+ /** ISO 8601 instant. Defaults to the start of the current month. */
70
+ initialFrom?: string;
71
+ /** ISO 8601 instant. Defaults to now. */
72
+ initialTo?: string;
73
+ /** Rows per ranked panel. Defaults to 10. */
74
+ topN?: number;
75
+ };
76
+
77
+ /**
78
+ * Owns every filter the administrative token-usage page reads, fetches the five
79
+ * panels behind it, and publishes the filter bar into the page title bar.
80
+ *
81
+ * The filter bar is rendered into `title.functions` here — NOT in the container —
82
+ * because `RoundPageContainer`'s title bar reads `title.functions` from
83
+ * `SharedContext`, and a descendant cannot inject nodes into an ancestor's
84
+ * provider value. That is why the filter state lives at this level.
85
+ */
86
+ export const TokenUsageAdminProvider = ({
87
+ children,
88
+ initialCompanyId,
89
+ initialFrom,
90
+ initialTo,
91
+ topN = DEFAULT_TOP_N,
92
+ }: TokenUsageAdminProviderProps) => {
93
+ const t = useTranslations();
94
+ const generateUrl = usePageUrlGenerator();
95
+
96
+ const [filters, setFilterState] = useState<TokenUsageAdminFilterState>(() => {
97
+ const range = defaultRange();
98
+ return {
99
+ from: initialFrom ?? range.from,
100
+ to: initialTo ?? range.to,
101
+ granularity: "day",
102
+ stackBy: "scope",
103
+ companyId: initialCompanyId,
104
+ metric: "cost",
105
+ };
106
+ });
107
+
108
+ const [summary, setSummary] = useState<TokenUsageAdminSummaryInterface[]>([]);
109
+ const [timeline, setTimeline] = useState<TokenUsageAdminTimelineInterface[]>([]);
110
+ const [byCompany, setByCompany] = useState<TokenUsageAdminBreakdownInterface[]>([]);
111
+ const [byUser, setByUser] = useState<TokenUsageAdminBreakdownInterface[]>([]);
112
+ const [byOperation, setByOperation] = useState<TokenUsageAdminBreakdownInterface[]>([]);
113
+ const [byCustomerOperation, setByCustomerOperation] = useState<TokenUsageAdminBreakdownInterface[]>([]);
114
+ const [isLoading, setIsLoading] = useState(true);
115
+ const [error, setError] = useState<string | null>(null);
116
+
117
+ const { from, to, granularity, stackBy, companyId, metric } = filters;
118
+ const singleCustomerMode = Boolean(companyId);
119
+
120
+ useEffect(() => {
121
+ // `cancelled` is the out-of-order guard: a filter change fires a new request
122
+ // while the previous one is still in flight, and without this flag the slower
123
+ // (older) response would land last and overwrite the fresher state.
124
+ let cancelled = false;
125
+
126
+ setIsLoading(true);
127
+ setError(null);
128
+
129
+ const base = { from, to, companyId };
130
+
131
+ Promise.all([
132
+ TokenUsageAdminService.getSummary(base),
133
+ TokenUsageAdminService.getTimeline({ ...base, granularity, stackBy }),
134
+ TokenUsageAdminService.getBreakdown({ ...base, dimension: "company", scope: "customer", limit: topN }),
135
+ TokenUsageAdminService.getBreakdown({ ...base, dimension: "user", scope: "customer", limit: topN }),
136
+ // Platform spend is, by definition, the usage with no owning company, so
137
+ // filtering it by one is meaningless — the call is skipped entirely rather
138
+ // than issued and discarded.
139
+ companyId
140
+ ? Promise.resolve<TokenUsageAdminBreakdownInterface[]>([])
141
+ : TokenUsageAdminService.getBreakdown({ ...base, dimension: "operation", scope: "platform", limit: topN }),
142
+ // Customer spend by operation — unlike the platform panel this one IS
143
+ // meaningful under a company filter, so it is always requested.
144
+ TokenUsageAdminService.getBreakdown({ ...base, dimension: "operation", scope: "customer", limit: topN }),
145
+ ])
146
+ .then(([nextSummary, nextTimeline, nextByCompany, nextByUser, nextByOperation, nextByCustomerOperation]) => {
147
+ if (cancelled) return;
148
+ setSummary(nextSummary ?? []);
149
+ setTimeline(nextTimeline ?? []);
150
+ setByCompany(nextByCompany ?? []);
151
+ setByUser(nextByUser ?? []);
152
+ setByOperation(nextByOperation ?? []);
153
+ setByCustomerOperation(nextByCustomerOperation ?? []);
154
+ })
155
+ .catch((err) => {
156
+ if (cancelled) return;
157
+ console.error("Failed to load administrative token usage:", err);
158
+ setError(err instanceof Error ? err.message : String(err));
159
+ })
160
+ .finally(() => {
161
+ if (cancelled) return;
162
+ setIsLoading(false);
163
+ });
164
+
165
+ return () => {
166
+ cancelled = true;
167
+ };
168
+ }, [from, to, granularity, stackBy, companyId, topN]);
169
+
170
+ const setFilters = useCallback((next: Partial<TokenUsageAdminFilterState>) => {
171
+ setFilterState((prev) => ({ ...prev, ...next }));
172
+ }, []);
173
+
174
+ /**
175
+ * Derived from the company breakdown, per the page design. Note the
176
+ * consequence: once a company filter is applied the breakdown returns that
177
+ * company alone, so the selector narrows to it — clearing the filter restores
178
+ * the full list.
179
+ */
180
+ const companies = useMemo(
181
+ () => byCompany.filter((row) => row.id !== "other").map((row) => ({ id: row.id, label: row.label })),
182
+ [byCompany],
183
+ );
184
+
185
+ const breadcrumb = (): BreadcrumbItemData[] => [
186
+ {
187
+ name: t("token_usage.admin.title"),
188
+ href: generateUrl({ page: TOKEN_USAGE_ADMIN_PAGE_URL }),
189
+ },
190
+ ];
191
+
192
+ const title = () => ({
193
+ type: t("token_usage.admin.title"),
194
+ functions: (
195
+ <TokenUsageAdminFilterBar
196
+ key="tokenUsageAdminFilterBar"
197
+ from={from}
198
+ to={to}
199
+ granularity={granularity}
200
+ companyId={companyId}
201
+ metric={metric}
202
+ companies={companies}
203
+ onChange={setFilters}
204
+ />
205
+ ),
206
+ });
207
+
208
+ const contextValue = useMemo<TokenUsageAdminContextType>(
209
+ () => ({
210
+ summary,
211
+ timeline,
212
+ byCompany,
213
+ byUser,
214
+ byOperation,
215
+ byCustomerOperation,
216
+ companies,
217
+ filters,
218
+ setFilters,
219
+ singleCustomerMode,
220
+ isLoading,
221
+ error,
222
+ }),
223
+ [
224
+ summary,
225
+ timeline,
226
+ byCompany,
227
+ byUser,
228
+ byOperation,
229
+ byCustomerOperation,
230
+ companies,
231
+ filters,
232
+ setFilters,
233
+ singleCustomerMode,
234
+ isLoading,
235
+ error,
236
+ ],
237
+ );
238
+
239
+ return (
240
+ <SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
241
+ <TokenUsageAdminContext.Provider value={contextValue}>{children}</TokenUsageAdminContext.Provider>
242
+ </SharedProvider>
243
+ );
244
+ };
245
+
246
+ export const useTokenUsageAdmin = (): TokenUsageAdminContextType => {
247
+ const ctx = useContext(TokenUsageAdminContext);
248
+ if (!ctx) {
249
+ throw new Error("useTokenUsageAdmin() called outside <TokenUsageAdminProvider>.");
250
+ }
251
+ return ctx;
252
+ };
@@ -0,0 +1,80 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { TokenUsageAdminService } from "../../data/TokenUsageAdminService";
4
+ import { TokenUsageAdminProvider, useTokenUsageAdmin } from "../TokenUsageAdminContext";
5
+
6
+ vi.mock("../../data/TokenUsageAdminService", () => ({
7
+ TokenUsageAdminService: {
8
+ getSummary: vi.fn(async () => []),
9
+ getTimeline: vi.fn(async () => []),
10
+ getBreakdown: vi.fn(async () => []),
11
+ },
12
+ }));
13
+
14
+ function Probe() {
15
+ const ctx = useTokenUsageAdmin();
16
+ return <span data-testid="mode">{String(ctx.singleCustomerMode)}</span>;
17
+ }
18
+
19
+ describe("TokenUsageAdminContext", () => {
20
+ // The mocked service is module-level, so its call counts survive across tests.
21
+ // Without this reset the per-test `toHaveBeenCalledTimes` assertions would
22
+ // measure the whole file rather than one render.
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ it("throws when used outside its provider", () => {
28
+ expect(() => render(<Probe />)).toThrow();
29
+ });
30
+
31
+ it("requests every panel on mount and reports not-single-customer by default", async () => {
32
+ render(
33
+ <TokenUsageAdminProvider>
34
+ <Probe />
35
+ </TokenUsageAdminProvider>,
36
+ );
37
+
38
+ await waitFor(() => expect(TokenUsageAdminService.getSummary).toHaveBeenCalled());
39
+
40
+ // Four ranked panels: company, user, customer-by-operation, platform-by-operation.
41
+ // Asserted by dimension+scope rather than by count alone, so a wrong pairing
42
+ // fails here instead of silently rendering the wrong data.
43
+ const calls = (TokenUsageAdminService.getBreakdown as ReturnType<typeof vi.fn>).mock.calls.map(([args]) => [
44
+ args.dimension,
45
+ args.scope,
46
+ ]);
47
+ expect(calls).toEqual(
48
+ expect.arrayContaining([
49
+ ["company", "customer"],
50
+ ["user", "customer"],
51
+ ["operation", "customer"],
52
+ ["operation", "platform"],
53
+ ]),
54
+ );
55
+ expect(calls).toHaveLength(4);
56
+ expect(screen.getByTestId("mode")).toHaveTextContent("false");
57
+ });
58
+
59
+ it("skips only the PLATFORM operation breakdown once a company filter is applied", async () => {
60
+ render(
61
+ <TokenUsageAdminProvider initialCompanyId="c1">
62
+ <Probe />
63
+ </TokenUsageAdminProvider>,
64
+ );
65
+
66
+ await waitFor(() => expect(TokenUsageAdminService.getSummary).toHaveBeenCalled());
67
+
68
+ const calls = (TokenUsageAdminService.getBreakdown as ReturnType<typeof vi.fn>).mock.calls.map(([args]) => [
69
+ args.dimension,
70
+ args.scope,
71
+ ]);
72
+ // Platform spend has no owning company, so filtering it is meaningless and
73
+ // the call is skipped. The CUSTOMER operation panel is still meaningful and
74
+ // must survive — that distinction is the point of this test.
75
+ expect(calls).toContainEqual(["operation", "customer"]);
76
+ expect(calls).not.toContainEqual(["operation", "platform"]);
77
+ expect(calls).toHaveLength(3);
78
+ expect(screen.getByTestId("mode")).toHaveTextContent("true");
79
+ });
80
+ });