@carlonicora/nextjs-jsonapi 1.141.3 → 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 (117) hide show
  1. package/dist/AbstractService-B0T7h8fX.d.mts +124 -0
  2. package/dist/AbstractService-Cew0PD0L.d.ts +124 -0
  3. package/dist/{AssistantMessageInterface-DH1QwtC_.d.mts → AssistantMessageInterface-Ce8FcllX.d.mts} +1 -1
  4. package/dist/{AssistantMessageInterface-giWvsOrX.d.ts → AssistantMessageInterface-Dh0BpbP2.d.ts} +1 -1
  5. package/dist/{AuthComponent-DdxCFgUZ.d.mts → AuthComponent-Cik96ElA.d.mts} +1 -1
  6. package/dist/{AuthComponent-Cd7lcYif.d.ts → AuthComponent-DTTKu3VK.d.ts} +1 -1
  7. package/dist/{BlockNoteEditor-JTKEBS2W.mjs → BlockNoteEditor-34QM4UR5.mjs} +4 -4
  8. package/dist/{BlockNoteEditor-4OXY6BKF.js → BlockNoteEditor-LMKWUOJG.js} +19 -19
  9. package/dist/{BlockNoteEditor-4OXY6BKF.js.map → BlockNoteEditor-LMKWUOJG.js.map} +1 -1
  10. package/dist/{auth.interface-yivTb46w.d.ts → auth.interface-COmBjh89.d.ts} +1 -1
  11. package/dist/{auth.interface-r3XKYOrQ.d.mts → auth.interface-CkmZqat3.d.mts} +1 -1
  12. package/dist/billing/index.js +357 -357
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/{chunk-5GVY2M5X.mjs → chunk-BIL2WQEV.mjs} +264 -18
  15. package/dist/chunk-BIL2WQEV.mjs.map +1 -0
  16. package/dist/{chunk-AGKZCRG2.js → chunk-DS4IBFWA.js} +269 -23
  17. package/dist/chunk-DS4IBFWA.js.map +1 -0
  18. package/dist/{chunk-4TGGHNWL.js → chunk-F6PZZ4KI.js} +752 -752
  19. package/dist/chunk-F6PZZ4KI.js.map +1 -0
  20. package/dist/{chunk-KXTTYXJ6.mjs → chunk-HN5ED7OF.mjs} +2 -2
  21. package/dist/{chunk-K76QSQTC.mjs → chunk-UGSOBBZH.mjs} +28 -28
  22. package/dist/chunk-UGSOBBZH.mjs.map +1 -0
  23. package/dist/{chunk-SE2YSAER.js → chunk-UXJAS6C5.js} +7 -7
  24. package/dist/{chunk-SE2YSAER.js.map → chunk-UXJAS6C5.js.map} +1 -1
  25. package/dist/client/index.d.mts +5 -5
  26. package/dist/client/index.d.ts +5 -5
  27. package/dist/client/index.js +4 -4
  28. package/dist/client/index.mjs +3 -3
  29. package/dist/components/index.d.mts +6 -6
  30. package/dist/components/index.d.ts +6 -6
  31. package/dist/components/index.js +4 -4
  32. package/dist/components/index.mjs +3 -3
  33. package/dist/{config-BrkU_Ynz.d.ts → config-Cd5xTEfZ.d.ts} +1 -1
  34. package/dist/{config-snd2ToEB.d.mts → config-YyhFHxkU.d.mts} +1 -1
  35. package/dist/contexts/index.d.mts +4 -4
  36. package/dist/contexts/index.d.ts +4 -4
  37. package/dist/contexts/index.js +4 -4
  38. package/dist/contexts/index.mjs +3 -3
  39. package/dist/core/index.d.mts +24 -60
  40. package/dist/core/index.d.ts +24 -60
  41. package/dist/core/index.js +8 -2
  42. package/dist/core/index.js.map +1 -1
  43. package/dist/core/index.mjs +7 -1
  44. package/dist/features/help/index.d.mts +2 -2
  45. package/dist/features/help/index.d.ts +2 -2
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.css +29 -0
  49. package/dist/features/tokenusage/index.css.map +1 -0
  50. package/dist/features/tokenusage/index.d.mts +388 -0
  51. package/dist/features/tokenusage/index.d.ts +388 -0
  52. package/dist/features/tokenusage/index.js +1012 -0
  53. package/dist/features/tokenusage/index.js.map +1 -0
  54. package/dist/features/tokenusage/index.mjs +1012 -0
  55. package/dist/features/tokenusage/index.mjs.map +1 -0
  56. package/dist/index.d.mts +11 -9
  57. package/dist/index.d.ts +11 -9
  58. package/dist/index.js +9 -3
  59. package/dist/index.js.map +1 -1
  60. package/dist/index.mjs +8 -2
  61. package/dist/{notification.interface-LKFmZndR.d.ts → notification.interface--27exGde.d.ts} +6 -6
  62. package/dist/{notification.interface-B3kNUTZs.d.mts → notification.interface-CG1vqoxg.d.mts} +6 -6
  63. package/dist/{s3.service-BrmeMjtk.d.ts → s3.service-CpwA3sl_.d.ts} +4 -125
  64. package/dist/{s3.service-CXKV_OT-.d.mts → s3.service-Dt6MfuHr.d.mts} +4 -125
  65. package/dist/server/index.d.mts +5 -4
  66. package/dist/server/index.d.ts +5 -4
  67. package/dist/server/index.js +3 -3
  68. package/dist/server/index.mjs +1 -1
  69. package/dist/tokenusage-admin.module-5wJ_tZTj.d.ts +61 -0
  70. package/dist/tokenusage-admin.module-DQVT4O4j.d.mts +61 -0
  71. package/dist/{types-CQSjy7et.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
  72. package/dist/{types-DHOxe8rc.d.ts → types-bsm8Fp55.d.ts} +1 -1
  73. package/dist/{usePageUrlGenerator-tjq2mlDV.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
  74. package/dist/{usePageUrlGenerator-uOnyJ6j2.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
  75. package/dist/{useSocket-BsbaGPqE.d.ts → useSocket-DgGAQW6h.d.ts} +1 -1
  76. package/dist/{useSocket-BSjn1Rlb.d.mts → useSocket-M3YY0aTS.d.mts} +1 -1
  77. package/package.json +6 -1
  78. package/src/core/index.ts +5 -0
  79. package/src/core/registry/ModuleRegistry.ts +4 -0
  80. package/src/features/company/components/details/TokenStatusIndicator.tsx +9 -9
  81. package/src/features/company/data/company.interface.ts +6 -6
  82. package/src/features/company/data/company.ts +17 -17
  83. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +189 -0
  84. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +138 -0
  85. package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +177 -0
  86. package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +160 -0
  87. package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +99 -0
  88. package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +225 -0
  89. package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +42 -0
  90. package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +41 -0
  91. package/src/features/tokenusage/components/__tests__/TokenUsageTimelineChart.spec.tsx +47 -0
  92. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +252 -0
  93. package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +80 -0
  94. package/src/features/tokenusage/data/TokenUsageAdminService.ts +54 -0
  95. package/src/features/tokenusage/data/__tests__/tokenusage-admin-models.spec.ts +147 -0
  96. package/src/features/tokenusage/data/index.ts +8 -0
  97. package/src/features/tokenusage/data/tokenusage-admin-breakdown.interface.ts +18 -0
  98. package/src/features/tokenusage/data/tokenusage-admin-breakdown.ts +115 -0
  99. package/src/features/tokenusage/data/tokenusage-admin-summary.interface.ts +14 -0
  100. package/src/features/tokenusage/data/tokenusage-admin-summary.ts +90 -0
  101. package/src/features/tokenusage/data/tokenusage-admin-timeline.interface.ts +13 -0
  102. package/src/features/tokenusage/data/tokenusage-admin-timeline.ts +94 -0
  103. package/src/features/tokenusage/data/tokenusage-admin.types.ts +56 -0
  104. package/src/features/tokenusage/i18n-keys.ts +65 -0
  105. package/src/features/tokenusage/index.ts +30 -0
  106. package/src/features/tokenusage/lib/metrics.ts +69 -0
  107. package/src/features/tokenusage/lib/operation-label.ts +41 -0
  108. package/src/features/tokenusage/lib/palette.ts +197 -0
  109. package/src/features/tokenusage/tokenusage-admin.module.ts +22 -0
  110. package/src/features/user/contexts/CurrentUserContext.tsx +18 -18
  111. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +21 -21
  112. package/dist/chunk-4TGGHNWL.js.map +0 -1
  113. package/dist/chunk-5GVY2M5X.mjs.map +0 -1
  114. package/dist/chunk-AGKZCRG2.js.map +0 -1
  115. package/dist/chunk-K76QSQTC.mjs.map +0 -1
  116. /package/dist/{BlockNoteEditor-JTKEBS2W.mjs.map → BlockNoteEditor-34QM4UR5.mjs.map} +0 -0
  117. /package/dist/{chunk-KXTTYXJ6.mjs.map → chunk-HN5ED7OF.mjs.map} +0 -0
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useMemo, useState } from "react";
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../shadcnui";
7
+ import { cn } from "../../../utils";
8
+ import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
9
+ import type { Metric } from "../data/tokenusage-admin.types";
10
+ import { cacheHitPercentage, formatDecimal, formatMetricValue, formatPercent, metricValue } from "../lib/metrics";
11
+
12
+ type Props = {
13
+ rows: TokenUsageAdminBreakdownInterface[];
14
+ metric: Metric;
15
+ };
16
+
17
+ type SortKey =
18
+ "label" | "sublabel" | "activeUsers" | "calls" | "tokensIn" | "tokensOut" | "cacheHit" | "cost" | "credits" | "share";
19
+
20
+ type SortState = { key: SortKey; direction: "asc" | "desc" };
21
+
22
+ /**
23
+ * The numeric detail behind the ranked bars: every row, every metric column.
24
+ *
25
+ * The bars answer "who is biggest"; this answers "why". Sorting is client-side
26
+ * because the whole ranked set — top-N plus the "other" rollup — is already in
27
+ * memory, so a round trip would buy nothing.
28
+ */
29
+ export function TokenUsageBreakdownTable({ rows, metric }: Props) {
30
+ const t = useTranslations();
31
+ const [sort, setSort] = useState<SortState | undefined>(undefined);
32
+
33
+ // The company dimension is the only one that reports active users. Rather than
34
+ // render an empty column on user/operation breakdowns, the column disappears.
35
+ const showActiveUsers = rows.some((row) => row.activeUsers !== undefined);
36
+
37
+ const total = useMemo(() => rows.reduce((sum, row) => sum + metricValue(row, metric), 0), [rows, metric]);
38
+
39
+ const sortValue = (row: TokenUsageAdminBreakdownInterface, key: SortKey): number | string => {
40
+ switch (key) {
41
+ case "label":
42
+ return row.label ?? "";
43
+ case "sublabel":
44
+ return row.sublabel ?? "";
45
+ case "activeUsers":
46
+ return row.activeUsers ?? 0;
47
+ case "cacheHit":
48
+ return cacheHitPercentage(row.cached, row.tokensIn);
49
+ case "share":
50
+ return metricValue(row, metric);
51
+ default:
52
+ return row[key];
53
+ }
54
+ };
55
+
56
+ const sortedRows = useMemo(() => {
57
+ if (!sort) return rows;
58
+
59
+ const factor = sort.direction === "desc" ? -1 : 1;
60
+ return [...rows].sort((a, b) => {
61
+ const left = sortValue(a, sort.key);
62
+ const right = sortValue(b, sort.key);
63
+ if (typeof left === "string" || typeof right === "string") {
64
+ return String(left).localeCompare(String(right), "it-IT") * factor;
65
+ }
66
+ return (left - right) * factor;
67
+ });
68
+ }, [rows, sort, metric]);
69
+
70
+ // First click on a column sorts descending: on a spend table the interesting
71
+ // end of every numeric column is the top one.
72
+ const toggleSort = (key: SortKey) =>
73
+ setSort((current) =>
74
+ current?.key === key
75
+ ? { key, direction: current.direction === "desc" ? "asc" : "desc" }
76
+ : { key, direction: "desc" },
77
+ );
78
+
79
+ const columns: { key: SortKey; label: string; numeric: boolean }[] = [
80
+ { key: "label", label: t("token_usage.admin.columns.label"), numeric: false },
81
+ { key: "sublabel", label: t("token_usage.admin.columns.sublabel"), numeric: false },
82
+ ...(showActiveUsers
83
+ ? [{ key: "activeUsers" as const, label: t("token_usage.admin.columns.active_users"), numeric: true }]
84
+ : []),
85
+ { key: "calls", label: t("token_usage.admin.columns.calls"), numeric: true },
86
+ { key: "tokensIn", label: t("token_usage.admin.columns.tokens_in"), numeric: true },
87
+ { key: "tokensOut", label: t("token_usage.admin.columns.tokens_out"), numeric: true },
88
+ // Reuses the tile's key rather than minting a column-specific one — same
89
+ // metric, same words.
90
+ { key: "cacheHit", label: t("token_usage.admin.cache_hit"), numeric: true },
91
+ { key: "cost", label: t("token_usage.admin.columns.cost"), numeric: true },
92
+ { key: "credits", label: t("token_usage.admin.columns.credits"), numeric: true },
93
+ { key: "share", label: t("token_usage.admin.columns.share"), numeric: true },
94
+ ];
95
+
96
+ return (
97
+ // The package's <Table> wraps itself in an `overflow-x-clip` container, which
98
+ // would swallow the overflow before this scroller ever saw it — hence the
99
+ // child override. Wide metric tables must scroll, never widen the page.
100
+ <div className="overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible">
101
+ <Table>
102
+ <TableHeader>
103
+ <TableRow>
104
+ {columns.map((column) => (
105
+ <TableHead key={column.key} className={cn(column.numeric && "text-right")}>
106
+ <button
107
+ type="button"
108
+ onClick={() => toggleSort(column.key)}
109
+ className={cn(
110
+ "hover:text-primary inline-flex items-center gap-1 text-xs font-medium",
111
+ column.numeric && "flex-row-reverse",
112
+ )}
113
+ >
114
+ {column.label}
115
+ {sort?.key === column.key &&
116
+ (sort.direction === "desc" ? (
117
+ <ArrowDownIcon aria-hidden className="size-3" />
118
+ ) : (
119
+ <ArrowUpIcon aria-hidden className="size-3" />
120
+ ))}
121
+ </button>
122
+ </TableHead>
123
+ ))}
124
+ </TableRow>
125
+ </TableHeader>
126
+ <TableBody>
127
+ {sortedRows.map((row) => {
128
+ const value = metricValue(row, metric);
129
+ const share = total > 0 ? (value / total) * 100 : 0;
130
+
131
+ return (
132
+ <TableRow key={row.id} data-testid={`breakdown-row-${row.id}`}>
133
+ <TableCell className="text-xs">
134
+ {row.id === "other" ? t("token_usage.admin.other") : row.label}
135
+ </TableCell>
136
+ <TableCell className="text-muted-foreground text-xs">{row.sublabel ?? ""}</TableCell>
137
+ {showActiveUsers && (
138
+ <TableCell className="text-right text-xs tabular-nums">
139
+ {row.activeUsers === undefined ? "" : formatDecimal(row.activeUsers, 0)}
140
+ </TableCell>
141
+ )}
142
+ <TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.calls, 0)}</TableCell>
143
+ <TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.tokensIn, 0)}</TableCell>
144
+ <TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.tokensOut, 0)}</TableCell>
145
+ <TableCell className="text-right text-xs tabular-nums">
146
+ {formatPercent(cacheHitPercentage(row.cached, row.tokensIn))}
147
+ </TableCell>
148
+ <TableCell className="text-right text-xs tabular-nums">{formatMetricValue(row.cost, "cost")}</TableCell>
149
+ <TableCell className="text-right text-xs tabular-nums">
150
+ {formatMetricValue(row.credits, "credits")}
151
+ </TableCell>
152
+ <TableCell className="text-right text-xs tabular-nums">{formatPercent(share)}</TableCell>
153
+ </TableRow>
154
+ );
155
+ })}
156
+ </TableBody>
157
+ </Table>
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
5
+ import type { Metric } from "../data/tokenusage-admin.types";
6
+ import { formatMetricValue, formatPercent, metricValue } from "../lib/metrics";
7
+ import { operationLabel } from "../lib/operation-label";
8
+ import { SEQUENTIAL_RAMP } from "../lib/palette";
9
+
10
+ type Props = {
11
+ /** Ranked rows, already ordered descending by the backend, "other" last. */
12
+ rows: TokenUsageAdminBreakdownInterface[];
13
+ metric: Metric;
14
+ /** Italian copy shown when there is nothing to rank. */
15
+ emptyLabel: string;
16
+ /**
17
+ * Whether `row.label` is an operation TYPE (vocabulary the consuming app
18
+ * translates) rather than an entity NAME (data, rendered verbatim).
19
+ *
20
+ * Company and user rows carry names — translating them would be nonsense — so
21
+ * this defaults to false and only the platform-by-operation panel opts in.
22
+ */
23
+ labelsAreOperationTypes?: boolean;
24
+ };
25
+
26
+ /**
27
+ * A ranked horizontal bar list — "who spent the most".
28
+ *
29
+ * This does a MAGNITUDE job, not a categorical one, so colour comes from a
30
+ * single sequential ramp indexed by RANK POSITION, never from a categorical
31
+ * palette keyed by entity. That is deliberate: a filter change reorders the
32
+ * rows, and rank-indexed colour simply re-shades them instead of repainting a
33
+ * company into another company's identity hue.
34
+ *
35
+ * There is exactly one series, so there is no legend: every bar is
36
+ * direct-labelled with its value and its share of the total.
37
+ */
38
+ export function TokenUsageRankedBar({ rows, metric, emptyLabel, labelsAreOperationTypes = false }: Props) {
39
+ const t = useTranslations();
40
+
41
+ const renderLabel = (row: TokenUsageAdminBreakdownInterface): string => {
42
+ if (row.id === "other") return t("token_usage.admin.other");
43
+ if (!labelsAreOperationTypes) return row.label;
44
+ return operationLabel(
45
+ row.label,
46
+ (key) => t(key),
47
+ (key) => t.has(key),
48
+ );
49
+ };
50
+
51
+ if (rows.length === 0) {
52
+ return <p className="text-muted-foreground py-6 text-center text-sm">{emptyLabel}</p>;
53
+ }
54
+
55
+ const values = rows.map((row) => metricValue(row, metric));
56
+ const total = values.reduce((sum, value) => sum + value, 0);
57
+ const max = Math.max(...values, 0);
58
+
59
+ return (
60
+ <div className="grid gap-0.5">
61
+ {rows.map((row, index) => {
62
+ const value = values[index];
63
+ // The backend folds everything past the limit into one "other" row; it
64
+ // gets the most recessive ramp step so it never out-shouts a real entity.
65
+ const isOther = row.id === "other";
66
+ const step = isOther ? SEQUENTIAL_RAMP.length - 1 : Math.min(index, SEQUENTIAL_RAMP.length - 1);
67
+ const width = max > 0 ? (value / max) * 100 : 0;
68
+ // Shares are taken against the supplied rows, which already include
69
+ // "other" — so the column sums to 100 %.
70
+ const share = total > 0 ? (value / total) * 100 : 0;
71
+
72
+ return (
73
+ <div key={row.id} className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-0.5 py-0.5">
74
+ <div className="grid min-w-0 gap-0.5">
75
+ <span className="text-muted-foreground truncate text-xs">{renderLabel(row)}</span>
76
+ <div className="bg-muted h-2 w-full overflow-hidden rounded-r-[4px]">
77
+ <div
78
+ data-testid={`ranked-fill-${row.id}`}
79
+ data-ramp-step={String(step)}
80
+ className="h-full rounded-r-[4px]"
81
+ style={{ width: `${width}%`, backgroundColor: SEQUENTIAL_RAMP[step] }}
82
+ />
83
+ </div>
84
+ </div>
85
+ <span data-testid={`ranked-value-${row.id}`} className="text-right text-xs tabular-nums">
86
+ {formatMetricValue(value, metric)}
87
+ </span>
88
+ <span
89
+ data-testid={`ranked-share-${row.id}`}
90
+ className="text-muted-foreground w-16 text-right text-xs tabular-nums"
91
+ >
92
+ {formatPercent(share)}
93
+ </span>
94
+ </div>
95
+ );
96
+ })}
97
+ </div>
98
+ );
99
+ }
@@ -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
+ });