@carlonicora/nextjs-jsonapi 2.4.0 → 2.5.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 (85) hide show
  1. package/dist/{BlockNoteEditor-7WGCH2KB.js → BlockNoteEditor-TIMGYKAA.js} +19 -19
  2. package/dist/{BlockNoteEditor-7WGCH2KB.js.map → BlockNoteEditor-TIMGYKAA.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-GWAV5ESO.mjs → BlockNoteEditor-YFJMXJEU.mjs} +4 -4
  4. package/dist/billing/index.js +362 -362
  5. package/dist/billing/index.mjs +3 -3
  6. package/dist/{chunk-GYGMEDVS.js → chunk-7HKJNUW7.js} +755 -746
  7. package/dist/chunk-7HKJNUW7.js.map +1 -0
  8. package/dist/{chunk-3LVSA7IM.mjs → chunk-DJWNNV52.mjs} +2 -2
  9. package/dist/{chunk-LCCRUWWY.mjs → chunk-FS4RVV3K.mjs} +241 -1
  10. package/dist/chunk-FS4RVV3K.mjs.map +1 -0
  11. package/dist/{chunk-M2EGUO2F.mjs → chunk-G4YS52SO.mjs} +16 -7
  12. package/dist/chunk-G4YS52SO.mjs.map +1 -0
  13. package/dist/{chunk-TOKHHZSP.js → chunk-OTEDZ6YW.js} +7 -7
  14. package/dist/{chunk-TOKHHZSP.js.map → chunk-OTEDZ6YW.js.map} +1 -1
  15. package/dist/{chunk-V66AZWPG.js → chunk-RTOC53EZ.js} +246 -6
  16. package/dist/chunk-RTOC53EZ.js.map +1 -0
  17. package/dist/client/index.js +4 -4
  18. package/dist/client/index.mjs +3 -3
  19. package/dist/components/index.d.mts +16 -1
  20. package/dist/components/index.d.ts +16 -1
  21. package/dist/components/index.js +4 -4
  22. package/dist/components/index.mjs +3 -3
  23. package/dist/{tokenusage-admin.module-5wJ_tZTj.d.ts → config-BIjrg5wd.d.ts} +39 -1
  24. package/dist/{tokenusage-admin.module-DQVT4O4j.d.mts → config-BRUjtCd1.d.mts} +39 -1
  25. package/dist/contexts/index.js +4 -4
  26. package/dist/contexts/index.mjs +3 -3
  27. package/dist/core/index.d.mts +5 -2
  28. package/dist/core/index.d.ts +5 -2
  29. package/dist/core/index.js +8 -2
  30. package/dist/core/index.js.map +1 -1
  31. package/dist/core/index.mjs +7 -1
  32. package/dist/features/help/index.js +37 -37
  33. package/dist/features/help/index.mjs +3 -3
  34. package/dist/features/tokenusage/index.d.mts +413 -14
  35. package/dist/features/tokenusage/index.d.ts +413 -14
  36. package/dist/features/tokenusage/index.js +481 -120
  37. package/dist/features/tokenusage/index.js.map +1 -1
  38. package/dist/features/tokenusage/index.mjs +431 -70
  39. package/dist/features/tokenusage/index.mjs.map +1 -1
  40. package/dist/index.d.mts +2 -2
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +9 -3
  43. package/dist/index.js.map +1 -1
  44. package/dist/index.mjs +8 -2
  45. package/dist/server/index.js +3 -3
  46. package/dist/server/index.mjs +1 -1
  47. package/package.json +1 -1
  48. package/src/core/index.ts +10 -0
  49. package/src/core/registry/ModuleRegistry.ts +4 -0
  50. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +20 -4
  51. package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +31 -18
  52. package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +13 -13
  53. package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +24 -7
  54. package/src/features/tokenusage/components/TokenUsageReportContainer.tsx +102 -0
  55. package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +34 -0
  56. package/src/features/tokenusage/components/TokenUsageReportTiles.tsx +161 -0
  57. package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +15 -13
  58. package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +4 -2
  59. package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +3 -1
  60. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +6 -3
  61. package/src/features/tokenusage/contexts/TokenUsageReportContext.tsx +199 -0
  62. package/src/features/tokenusage/data/TokenUsageReportService.ts +65 -0
  63. package/src/features/tokenusage/data/index.ts +9 -0
  64. package/src/features/tokenusage/data/tokenusage-report-breakdown.interface.ts +12 -0
  65. package/src/features/tokenusage/data/tokenusage-report-breakdown.ts +94 -0
  66. package/src/features/tokenusage/data/tokenusage-report-summary.interface.ts +12 -0
  67. package/src/features/tokenusage/data/tokenusage-report-summary.ts +87 -0
  68. package/src/features/tokenusage/data/tokenusage-report-timeline.interface.ts +13 -0
  69. package/src/features/tokenusage/data/tokenusage-report-timeline.ts +94 -0
  70. package/src/features/tokenusage/data/tokenusage-report.types.ts +51 -0
  71. package/src/features/tokenusage/i18n-keys.ts +28 -0
  72. package/src/features/tokenusage/index.ts +18 -0
  73. package/src/features/tokenusage/lib/config.ts +22 -0
  74. package/src/features/tokenusage/lib/formatters.ts +100 -0
  75. package/src/features/tokenusage/lib/metrics.ts +8 -26
  76. package/src/features/tokenusage/lib/palette.ts +41 -14
  77. package/src/features/tokenusage/tokenusage-admin.module.ts +4 -0
  78. package/src/features/tokenusage/tokenusage.modules.ts +40 -0
  79. package/src/shadcnui/ui/chart.tsx +30 -3
  80. package/dist/chunk-GYGMEDVS.js.map +0 -1
  81. package/dist/chunk-LCCRUWWY.mjs.map +0 -1
  82. package/dist/chunk-M2EGUO2F.mjs.map +0 -1
  83. package/dist/chunk-V66AZWPG.js.map +0 -1
  84. /package/dist/{BlockNoteEditor-GWAV5ESO.mjs.map → BlockNoteEditor-YFJMXJEU.mjs.map} +0 -0
  85. /package/dist/{chunk-3LVSA7IM.mjs.map → chunk-DJWNNV52.mjs.map} +0 -0
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { RoundPageContainer } from "../../../components";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "../../../shadcnui";
6
+ import { cn } from "../../../utils";
7
+ import { useTokenUsageReport } from "../contexts/TokenUsageReportContext";
8
+ import { TokenUsageRankedBar } from "./TokenUsageRankedBar";
9
+ import { TokenUsageReportTiles, type TokenUsageBalances } from "./TokenUsageReportTiles";
10
+ import { TokenUsageTimelineChart } from "./TokenUsageTimelineChart";
11
+
12
+ type Props = {
13
+ /**
14
+ * The caller's own credit balances. Supplied by the host app, which reads them
15
+ * from its CurrentUserContext — the package has no access to that context.
16
+ */
17
+ balances?: TokenUsageBalances | null;
18
+ };
19
+
20
+ /**
21
+ * Page body for the self-service token-usage dashboard.
22
+ *
23
+ * Stateless by design — every value comes from useTokenUsageReport(). The filter
24
+ * bar is deliberately NOT here: it belongs to the page title bar, which
25
+ * RoundPageContainer fills from SharedContext, so the provider publishes it.
26
+ *
27
+ * The timeline and the ranked bars are the PACKAGE'S EXISTING components, used
28
+ * verbatim. They take the same six metric getters the report interfaces were
29
+ * given, which is what makes that reuse possible.
30
+ */
31
+ export function TokenUsageReportContainer({ balances = null }: Props) {
32
+ const t = useTranslations();
33
+ const { summary, timeline, byOperation, byTarget, isLoading, error, targetPanelTitleKey } = useTokenUsageReport();
34
+
35
+ if (error) {
36
+ return (
37
+ <RoundPageContainer fullWidth forceHeader>
38
+ <div className="p-4">
39
+ <Card>
40
+ <CardContent>
41
+ <p className="text-destructive text-xs/relaxed">{error}</p>
42
+ </CardContent>
43
+ </Card>
44
+ </div>
45
+ </RoundPageContainer>
46
+ );
47
+ }
48
+
49
+ // Loading renders nothing in the body: the title bar (with the filter bar) is
50
+ // already mounted, so a spinner would only make the controls jump on arrival.
51
+ if (isLoading) return <RoundPageContainer fullWidth forceHeader />;
52
+
53
+ const emptyLabel = t("token_usage.report.no_data");
54
+ // The panel needs both a title the host app owns and rows to put under it.
55
+ const targetTitle = targetPanelTitleKey && byTarget.length > 0 ? t(targetPanelTitleKey) : undefined;
56
+
57
+ return (
58
+ <RoundPageContainer fullWidth forceHeader>
59
+ <div className="flex w-full flex-col gap-4 p-4">
60
+ <TokenUsageReportTiles summary={summary} metric="credits" balances={balances} />
61
+
62
+ <Card>
63
+ <CardHeader>
64
+ <CardTitle>{t("token_usage.report.usage_over_time")}</CardTitle>
65
+ </CardHeader>
66
+ <CardContent>
67
+ <TokenUsageTimelineChart rows={timeline} metric="credits" stackBy="type" />
68
+ </CardContent>
69
+ </Card>
70
+
71
+ <div className={cn("grid gap-4", targetTitle && "md:grid-cols-2")}>
72
+ <Card>
73
+ <CardHeader>
74
+ <CardTitle>{t("token_usage.report.by_operation")}</CardTitle>
75
+ </CardHeader>
76
+ <CardContent>
77
+ {/* Operation types are vocabulary the host app translates, unlike
78
+ the target panel's rows, which carry entity NAMES. */}
79
+ <TokenUsageRankedBar
80
+ rows={byOperation}
81
+ metric="credits"
82
+ emptyLabel={emptyLabel}
83
+ labelsAreOperationTypes
84
+ />
85
+ </CardContent>
86
+ </Card>
87
+
88
+ {targetTitle && (
89
+ <Card>
90
+ <CardHeader>
91
+ <CardTitle>{targetTitle}</CardTitle>
92
+ </CardHeader>
93
+ <CardContent>
94
+ <TokenUsageRankedBar rows={byTarget} metric="credits" emptyLabel={emptyLabel} />
95
+ </CardContent>
96
+ </Card>
97
+ )}
98
+ </div>
99
+ </div>
100
+ </RoundPageContainer>
101
+ );
102
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { DateRangeSelector } from "../../../components/forms/DateRangeSelector";
4
+
5
+ type Props = {
6
+ /** Receives ONLY the keys that changed. */
7
+ onChange: (next: { from?: string; to?: string }) => void;
8
+ };
9
+
10
+ /**
11
+ * The single control row above the KPI tiles.
12
+ *
13
+ * Deliberately stateless: the control reports the keys it changed and the owning
14
+ * context re-fetches, which is what lets the page issue a single coordinated
15
+ * batch of requests instead of one per control.
16
+ *
17
+ * There is NO metric selector. A game master is billed in credits, so credits
18
+ * are the only unit this surface speaks: cost would leak platform margin (the
19
+ * controller rejects metric=cost outright) and a raw token count is an
20
+ * implementation detail nobody is charged for. There is no company selector
21
+ * either — a self-service caller has exactly one company.
22
+ */
23
+ export function TokenUsageReportFilterBar({ onChange }: Props) {
24
+ return (
25
+ <div className="flex flex-wrap items-center gap-2">
26
+ <DateRangeSelector
27
+ onDateChange={(range) => {
28
+ if (!range?.from || !range?.to) return;
29
+ onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
30
+ }}
31
+ />
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,161 @@
1
+ "use client";
2
+
3
+ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { Card, CardContent } from "../../../shadcnui";
6
+ import { cn } from "../../../utils";
7
+ import type { TokenUsageReportSummaryInterface } from "../data/tokenusage-report-summary.interface";
8
+ import type { ReportMetric } from "../data/tokenusage-report.types";
9
+ import { useUsageFormatters } from "../lib/formatters";
10
+ import { metricValue, percentageDelta, type TokenUsageMetrics } from "../lib/metrics";
11
+
12
+ /**
13
+ * The caller's own credit position, as the host app reads it from its
14
+ * CurrentUserContext. The package has no access to that context, so the numbers
15
+ * arrive as a prop.
16
+ */
17
+ export type TokenUsageBalances = {
18
+ monthlyCredits: number;
19
+ availableMonthlyCredits: number;
20
+ availableExtraCredits: number;
21
+ };
22
+
23
+ type Props = {
24
+ /** Two rows: window "current" and "previous". */
25
+ summary: TokenUsageReportSummaryInterface[];
26
+ metric: ReportMetric;
27
+ /** The caller's own balances, read from CurrentUserContext by the container. */
28
+ balances: TokenUsageBalances | null;
29
+ };
30
+
31
+ const ZERO: TokenUsageMetrics = { cost: 0, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls: 0 };
32
+
33
+ /**
34
+ * The KPI header of the self-service token-usage page.
35
+ *
36
+ * One lead tile carries the number the page exists to answer — how much was
37
+ * spent in the period — with its delta against the equal-length preceding
38
+ * window. Three supporting tiles give it context: what is left this month, what
39
+ * extra sits behind that, and how many calls produced the spend.
40
+ *
41
+ * Credits are fractional. Customer-facing BALANCES are floored to whole credits
42
+ * (Math.max(0, Math.floor(v))) so nobody is told they have 715.4 of something
43
+ * indivisible; the percentage arithmetic behind the colour keeps the raw floats.
44
+ * Spend is NOT floored — it is a measurement, not a wallet.
45
+ */
46
+ export function TokenUsageReportTiles({ summary, metric, balances }: Props) {
47
+ const t = useTranslations();
48
+ const { decimal, metricValue: formatValue } = useUsageFormatters();
49
+
50
+ const rowFor = (window: string): TokenUsageMetrics => summary.find((row) => row.window === window) ?? ZERO;
51
+
52
+ const current = rowFor("current");
53
+ const previous = rowFor("previous");
54
+
55
+ const monthlyPercentage =
56
+ balances && balances.monthlyCredits > 0 ? (balances.availableMonthlyCredits / balances.monthlyCredits) * 100 : 0;
57
+
58
+ // Three bands, not four: the previous implementation had a dead branch where
59
+ // >= 25 and >= 5 both returned the same class.
60
+ const monthlyColor =
61
+ monthlyPercentage > 75 ? "text-success" : monthlyPercentage >= 5 ? "text-warning" : "text-destructive";
62
+
63
+ const whole = (value: number) => decimal(Math.max(0, Math.floor(value)), 0);
64
+
65
+ return (
66
+ <div className="grid gap-3">
67
+ <Card data-testid="tile-used">
68
+ <CardContent className="grid gap-1">
69
+ <span className="text-muted-foreground text-xs">{t("token_usage.report.used_in_period")}</span>
70
+ <span className="text-primary text-xl font-semibold tabular-nums">
71
+ {formatValue(metricValue(current, metric), metric)}
72
+ </span>
73
+ <span className="flex items-center gap-1">
74
+ <Delta
75
+ testId="tile-used-delta"
76
+ delta={percentageDelta(metricValue(current, metric), metricValue(previous, metric))}
77
+ decimal={decimal}
78
+ />
79
+ <span className="text-muted-foreground text-xs">{t("token_usage.report.vs_previous")}</span>
80
+ </span>
81
+ </CardContent>
82
+ </Card>
83
+
84
+ <div className="grid gap-3 sm:grid-cols-3">
85
+ {balances && (
86
+ <Card data-testid="tile-monthly" size="sm">
87
+ <CardContent className="grid gap-1">
88
+ <span className="text-muted-foreground text-xs">{t("token_usage.report.monthly_left")}</span>
89
+ <span className="flex items-baseline gap-1">
90
+ <span data-testid="tile-monthly-value" className={cn("text-sm font-medium tabular-nums", monthlyColor)}>
91
+ {whole(balances.availableMonthlyCredits)}
92
+ </span>
93
+ <span className="text-muted-foreground text-xs tabular-nums">/ {whole(balances.monthlyCredits)}</span>
94
+ </span>
95
+ </CardContent>
96
+ </Card>
97
+ )}
98
+
99
+ {balances && (
100
+ <Card data-testid="tile-extra" size="sm">
101
+ <CardContent className="grid gap-1">
102
+ <span className="text-muted-foreground text-xs">{t("token_usage.report.extra_credits")}</span>
103
+ <span className="text-sm font-medium tabular-nums">{whole(balances.availableExtraCredits)}</span>
104
+ </CardContent>
105
+ </Card>
106
+ )}
107
+
108
+ <Card data-testid="tile-calls" size="sm">
109
+ <CardContent className="grid gap-1">
110
+ <span className="text-muted-foreground text-xs">{t("token_usage.report.calls")}</span>
111
+ <span className="text-sm font-medium tabular-nums">{decimal(current.calls, 0)}</span>
112
+ </CardContent>
113
+ </Card>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /**
120
+ * The delta slot. An undefined delta means the previous window was zero: an em
121
+ * dash says "not comparable" where a percentage would say "infinite growth".
122
+ *
123
+ * `decimal` arrives as a prop rather than from the hook because this is a
124
+ * module-level function, not a component — calling a hook here would break the
125
+ * rules of hooks.
126
+ */
127
+ function Delta({
128
+ testId,
129
+ delta,
130
+ decimal,
131
+ }: {
132
+ testId: string;
133
+ delta: number | undefined;
134
+ decimal: (value: number, decimals: number) => string;
135
+ }) {
136
+ if (delta === undefined) {
137
+ return (
138
+ <span data-testid={testId} className="text-muted-foreground text-xs">
139
+
140
+ </span>
141
+ );
142
+ }
143
+
144
+ const increased = delta >= 0;
145
+ const Icon = increased ? ArrowUpIcon : ArrowDownIcon;
146
+
147
+ // On a SPEND page more is not better, so the semantics are inverted relative
148
+ // to the administrative tiles: rising spend reads as a warning, not a success.
149
+ return (
150
+ <span
151
+ data-testid={testId}
152
+ className={cn(
153
+ "inline-flex items-center gap-0.5 text-xs tabular-nums",
154
+ increased ? "text-warning" : "text-success",
155
+ )}
156
+ >
157
+ <Icon aria-hidden className="size-3" />
158
+ {decimal(Math.abs(delta), 0)} %
159
+ </span>
160
+ );
161
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from "../../../shadcnui";
15
15
  import { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface";
16
16
  import { Metric, StackBy } from "../data/tokenusage-admin.types";
17
+ import { useUsageFormatters } from "../lib/formatters";
17
18
  import { operationLabel } from "../lib/operation-label";
18
19
  import { CATEGORICAL_CEILING, ChartMode, OTHER_COLOR, seriesColor } from "../lib/palette";
19
20
 
@@ -71,13 +72,6 @@ const inferGranularity = (buckets: string[]): "day" | "week" | "month" => {
71
72
  return "day";
72
73
  };
73
74
 
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
75
  /**
82
76
  * Usage over time as a stacked bar chart.
83
77
  *
@@ -93,6 +87,7 @@ const compactNumber = new Intl.NumberFormat("it-IT", { notation: "compact", maxi
93
87
  */
94
88
  export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: TokenUsageTimelineChartProps) {
95
89
  const t = useTranslations();
90
+ const { compact, bucketDate, metricValue: formatValue } = useUsageFormatters();
96
91
  const { resolvedTheme } = useTheme();
97
92
  const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light";
98
93
 
@@ -134,7 +129,7 @@ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: To
134
129
  const seriesLabel = (series: string): string => {
135
130
  if (series === OTHER_SERIES) {
136
131
  const key = "token_usage.series.other";
137
- return t.has(key) ? t(key) : "Altro";
132
+ return t.has(key) ? t(key) : "Other";
138
133
  }
139
134
  // Stacking by company makes the series a company NAME, which is data, not
140
135
  // vocabulary — it is never looked up. Scope and type keys are vocabulary the
@@ -156,13 +151,12 @@ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: To
156
151
  const key = "token_usage.timeline.empty";
157
152
  return (
158
153
  <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"}
154
+ {t.has(key) ? t(key) : "No data in the selected period"}
160
155
  </p>
161
156
  );
162
157
  }
163
158
 
164
159
  const granularity = inferGranularity(chartData.map((entry) => entry.bucket));
165
- const formatBucket = bucketFormatter(granularity);
166
160
  const seriesColorFor = (series: string, index: number) =>
167
161
  series === OTHER_SERIES ? OTHER_COLOR : seriesColor(index, mode);
168
162
 
@@ -186,19 +180,27 @@ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: To
186
180
  axisLine={false}
187
181
  tickMargin={8}
188
182
  minTickGap={16}
189
- tickFormatter={(value: string) => formatBucket.format(new Date(`${value}T00:00:00.000Z`))}
183
+ tickFormatter={(value: string) => bucketDate(value, granularity)}
190
184
  />
191
185
  <YAxis
192
186
  tickLine={false}
193
187
  axisLine={false}
194
188
  tickMargin={8}
195
189
  width={48}
196
- tickFormatter={(value: number) => compactNumber.format(value)}
190
+ tickFormatter={(value: number) => compact(value)}
197
191
  />
198
192
  <ChartTooltip
199
193
  content={
200
194
  <ChartTooltipContent
201
- labelFormatter={(value) => formatBucket.format(new Date(`${String(value)}T00:00:00.000Z`))}
195
+ labelFormatter={(value) => bucketDate(String(value), granularity)}
196
+ // Without this the tooltip falls back to toLocaleString() and
197
+ // prints the raw stored precision (credits carry 4 decimals),
198
+ // which disagrees with every other number on the page.
199
+ valueFormatter={(value) => formatValue(Number(value), metric)}
200
+ // Every series appears in every bucket's payload, so a day with
201
+ // one active operation would otherwise list the whole vocabulary
202
+ // as zeros and bury the one number that matters.
203
+ hideZeroValues
202
204
  />
203
205
  }
204
206
  />
@@ -18,8 +18,10 @@ describe("TokenUsageAdminTiles", () => {
18
18
  it("shows both cost centres with their deltas against the previous window", () => {
19
19
  render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode={false} />);
20
20
 
21
- expect(screen.getByTestId("tile-customer")).toHaveTextContent("9,46");
22
- expect(screen.getByTestId("tile-platform")).toHaveTextContent("9,47");
21
+ // The suite mocks useLocale() as "en", and formatting now honours the
22
+ // locale instead of a hard-coded it-IT, so the separator is a point.
23
+ expect(screen.getByTestId("tile-customer")).toHaveTextContent("9.46");
24
+ expect(screen.getByTestId("tile-platform")).toHaveTextContent("9.47");
23
25
  // customer 9.46 vs 8.00 => +18%
24
26
  expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("18");
25
27
  // platform 9.47 vs 10.00 => -5%
@@ -15,7 +15,9 @@ describe("TokenUsageRankedBar", () => {
15
15
  />,
16
16
  );
17
17
 
18
- expect(screen.getByTestId("ranked-value-a")).toHaveTextContent("6,00");
18
+ // The suite mocks useLocale() as "en", and formatting now honours the
19
+ // locale instead of a hard-coded it-IT, so the separator is a point.
20
+ expect(screen.getByTestId("ranked-value-a")).toHaveTextContent("6.00");
19
21
  expect(screen.getByTestId("ranked-share-a")).toHaveTextContent("60");
20
22
  expect(screen.getByTestId("ranked-share-b")).toHaveTextContent("40");
21
23
  });
@@ -13,8 +13,8 @@ import type { Granularity, Metric, StackBy } from "../data/tokenusage-admin.type
13
13
  import { TokenUsageAdminService } from "../data/TokenUsageAdminService";
14
14
 
15
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
16
+ * Default route the breadcrumb links back to. A host app that mounts the page
17
+ * elsewhere overrides it with the `pageUrl` prop — the constant stays here
18
18
  * because the breadcrumb is built by this provider, not by the app.
19
19
  */
20
20
  const TOKEN_USAGE_ADMIN_PAGE_URL = "/administration/token-usage";
@@ -72,6 +72,8 @@ type TokenUsageAdminProviderProps = {
72
72
  initialTo?: string;
73
73
  /** Rows per ranked panel. Defaults to 10. */
74
74
  topN?: number;
75
+ /** Route the breadcrumb links back to. */
76
+ pageUrl?: string;
75
77
  };
76
78
 
77
79
  /**
@@ -89,6 +91,7 @@ export const TokenUsageAdminProvider = ({
89
91
  initialFrom,
90
92
  initialTo,
91
93
  topN = DEFAULT_TOP_N,
94
+ pageUrl = TOKEN_USAGE_ADMIN_PAGE_URL,
92
95
  }: TokenUsageAdminProviderProps) => {
93
96
  const t = useTranslations();
94
97
  const generateUrl = usePageUrlGenerator();
@@ -185,7 +188,7 @@ export const TokenUsageAdminProvider = ({
185
188
  const breadcrumb = (): BreadcrumbItemData[] => [
186
189
  {
187
190
  name: t("token_usage.admin.title"),
188
- href: generateUrl({ page: TOKEN_USAGE_ADMIN_PAGE_URL }),
191
+ href: generateUrl({ page: pageUrl }),
189
192
  },
190
193
  ];
191
194
 
@@ -0,0 +1,199 @@
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 { TokenUsageReportFilterBar } from "../components/TokenUsageReportFilterBar";
9
+ import type { TokenUsageReportBreakdownInterface } from "../data/tokenusage-report-breakdown.interface";
10
+ import type { TokenUsageReportSummaryInterface } from "../data/tokenusage-report-summary.interface";
11
+ import type { TokenUsageReportTimelineInterface } from "../data/tokenusage-report-timeline.interface";
12
+ import type { ReportMetric } from "../data/tokenusage-report.types";
13
+ import { TokenUsageReportService } from "../data/TokenUsageReportService";
14
+
15
+ /** Default page the breadcrumb links back to; overridable per host app. */
16
+ const TOKEN_USAGE_REPORT_PAGE_URL = "/tokenusage";
17
+
18
+ /** Rows kept per ranked panel before the backend folds the tail into "other". */
19
+ const DEFAULT_TOP_N = 10;
20
+
21
+ /**
22
+ * The unit this surface reports in, always. A game master is billed in credits,
23
+ * so credits are the only number that means anything to them: cost would leak
24
+ * platform margin and a raw token count is an implementation detail nobody is
25
+ * charged for. Pinned rather than exposed as a filter — there is no metric
26
+ * selector on the page.
27
+ */
28
+ const REPORT_METRIC: ReportMetric = "credits";
29
+
30
+ export type TokenUsageReportFilterState = {
31
+ /** ISO 8601 instant. */
32
+ from: string;
33
+ /** ISO 8601 instant. */
34
+ to: string;
35
+ };
36
+
37
+ export interface TokenUsageReportContextType {
38
+ summary: TokenUsageReportSummaryInterface[];
39
+ timeline: TokenUsageReportTimelineInterface[];
40
+ byOperation: TokenUsageReportBreakdownInterface[];
41
+ /** Empty unless the host app declared a targetLabel. */
42
+ byTarget: TokenUsageReportBreakdownInterface[];
43
+ /** i18n key for the target panel's title; undefined when the host set no targetLabel. */
44
+ targetPanelTitleKey?: string;
45
+ filters: TokenUsageReportFilterState;
46
+ /** Merges a partial patch into the current filters; every key is optional. */
47
+ setFilters: (next: Partial<TokenUsageReportFilterState>) => void;
48
+ isLoading: boolean;
49
+ error: string | null;
50
+ }
51
+
52
+ const TokenUsageReportContext = createContext<TokenUsageReportContextType | undefined>(undefined);
53
+
54
+ /** Current calendar month to now, which is the window the page opens on. */
55
+ function defaultRange(): { from: string; to: string } {
56
+ const now = new Date();
57
+ const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
58
+ return { from: start.toISOString(), to: now.toISOString() };
59
+ }
60
+
61
+ type TokenUsageReportProviderProps = {
62
+ children: ReactNode;
63
+ /**
64
+ * The Neo4j label the "by target" panel groups by, e.g. "Campaign". The set of
65
+ * things usage can be attributed to is application-specific, so the package
66
+ * cannot pick one — omit it and the panel is skipped rather than rendered
67
+ * empty, and no request is issued.
68
+ */
69
+ targetLabel?: string;
70
+ /** i18n key for the target panel's title. Required when targetLabel is set. */
71
+ targetPanelTitleKey?: string;
72
+ /** ISO 8601 instant. Defaults to the start of the current month. */
73
+ initialFrom?: string;
74
+ /** ISO 8601 instant. Defaults to now. */
75
+ initialTo?: string;
76
+ /** Rows per ranked panel. Defaults to 10. */
77
+ topN?: number;
78
+ /** Route the breadcrumb links back to. */
79
+ pageUrl?: string;
80
+ };
81
+
82
+ /**
83
+ * Owns the date range of the self-service token-usage page, fetches the three
84
+ * panels behind it, and publishes the filter bar into the page title bar.
85
+ *
86
+ * The filter bar is rendered into `title.functions` here — NOT in the container —
87
+ * because `RoundPageContainer`'s title bar reads `title.functions` from
88
+ * `SharedContext`, and a descendant cannot inject nodes into an ancestor's
89
+ * provider value. That is why the filter state lives at this level.
90
+ */
91
+ export const TokenUsageReportProvider = ({
92
+ children,
93
+ targetLabel,
94
+ targetPanelTitleKey,
95
+ initialFrom,
96
+ initialTo,
97
+ topN = DEFAULT_TOP_N,
98
+ pageUrl = TOKEN_USAGE_REPORT_PAGE_URL,
99
+ }: TokenUsageReportProviderProps) => {
100
+ const t = useTranslations();
101
+ const generateUrl = usePageUrlGenerator();
102
+
103
+ const [filters, setFilterState] = useState<TokenUsageReportFilterState>(() => {
104
+ const range = defaultRange();
105
+ return { from: initialFrom ?? range.from, to: initialTo ?? range.to };
106
+ });
107
+
108
+ const [summary, setSummary] = useState<TokenUsageReportSummaryInterface[]>([]);
109
+ const [timeline, setTimeline] = useState<TokenUsageReportTimelineInterface[]>([]);
110
+ const [byOperation, setByOperation] = useState<TokenUsageReportBreakdownInterface[]>([]);
111
+ const [byTarget, setByTarget] = useState<TokenUsageReportBreakdownInterface[]>([]);
112
+ const [isLoading, setIsLoading] = useState(true);
113
+ const [error, setError] = useState<string | null>(null);
114
+
115
+ const { from, to } = filters;
116
+
117
+ useEffect(() => {
118
+ // `cancelled` is the out-of-order guard: a filter change fires a new request
119
+ // while the previous one is still in flight, and without this flag the slower
120
+ // (older) response would land last and overwrite the fresher state.
121
+ let cancelled = false;
122
+
123
+ setIsLoading(true);
124
+ setError(null);
125
+
126
+ const base = { from, to };
127
+
128
+ Promise.all([
129
+ TokenUsageReportService.getSummary(base),
130
+ // granularity is pinned to "day": week/month bucketing buys nothing on the
131
+ // ranges a single tenant browses.
132
+ TokenUsageReportService.getTimeline({ ...base, granularity: "day" }),
133
+ TokenUsageReportService.getBreakdown({ ...base, dimension: "operation", metric: REPORT_METRIC, limit: topN }),
134
+ // No targetLabel means the host app never opted in, so the request is
135
+ // skipped entirely rather than issued and discarded.
136
+ targetLabel
137
+ ? TokenUsageReportService.getBreakdown({
138
+ ...base,
139
+ dimension: "target",
140
+ targetLabel,
141
+ metric: REPORT_METRIC,
142
+ limit: topN,
143
+ })
144
+ : Promise.resolve<TokenUsageReportBreakdownInterface[]>([]),
145
+ ])
146
+ .then(([nextSummary, nextTimeline, nextByOperation, nextByTarget]) => {
147
+ if (cancelled) return;
148
+ setSummary(nextSummary ?? []);
149
+ setTimeline(nextTimeline ?? []);
150
+ setByOperation(nextByOperation ?? []);
151
+ setByTarget(nextByTarget ?? []);
152
+ })
153
+ .catch((err) => {
154
+ if (cancelled) return;
155
+ console.error("Failed to load token usage:", err);
156
+ setError(err instanceof Error ? err.message : String(err));
157
+ })
158
+ .finally(() => {
159
+ if (cancelled) return;
160
+ setIsLoading(false);
161
+ });
162
+
163
+ return () => {
164
+ cancelled = true;
165
+ };
166
+ }, [from, to, targetLabel, topN]);
167
+
168
+ const setFilters = useCallback((next: Partial<TokenUsageReportFilterState>) => {
169
+ setFilterState((prev) => ({ ...prev, ...next }));
170
+ }, []);
171
+
172
+ const breadcrumb = (): BreadcrumbItemData[] => [
173
+ { name: t("token_usage.report.title"), href: generateUrl({ page: pageUrl }) },
174
+ ];
175
+
176
+ const title = () => ({
177
+ type: t("token_usage.report.title"),
178
+ functions: <TokenUsageReportFilterBar key="tokenUsageReportFilterBar" onChange={setFilters} />,
179
+ });
180
+
181
+ const contextValue = useMemo<TokenUsageReportContextType>(
182
+ () => ({ summary, timeline, byOperation, byTarget, targetPanelTitleKey, filters, setFilters, isLoading, error }),
183
+ [summary, timeline, byOperation, byTarget, targetPanelTitleKey, filters, setFilters, isLoading, error],
184
+ );
185
+
186
+ return (
187
+ <SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
188
+ <TokenUsageReportContext.Provider value={contextValue}>{children}</TokenUsageReportContext.Provider>
189
+ </SharedProvider>
190
+ );
191
+ };
192
+
193
+ export const useTokenUsageReport = (): TokenUsageReportContextType => {
194
+ const ctx = useContext(TokenUsageReportContext);
195
+ if (!ctx) {
196
+ throw new Error("useTokenUsageReport() called outside <TokenUsageReportProvider>.");
197
+ }
198
+ return ctx;
199
+ };