@carlonicora/nextjs-jsonapi 6.19.0 → 6.20.1

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 (105) hide show
  1. package/dist/{BlockNoteEditor-5HSL74IX.mjs → BlockNoteEditor-S7EVHYWT.mjs} +5 -5
  2. package/dist/{BlockNoteEditor-6RZXGMSD.js → BlockNoteEditor-V76V5XFY.js} +20 -20
  3. package/dist/{BlockNoteEditor-6RZXGMSD.js.map → BlockNoteEditor-V76V5XFY.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-X4UEVI2A.mjs → BlockNoteViewer-G3L46SGQ.mjs} +3 -3
  5. package/dist/{BlockNoteViewer-XYKFY77F.js → BlockNoteViewer-ZFJ3DZXN.js} +4 -4
  6. package/dist/{BlockNoteViewer-XYKFY77F.js.map → BlockNoteViewer-ZFJ3DZXN.js.map} +1 -1
  7. package/dist/{analytics.modules-Bsacu6ik.d.ts → analytics.modules-C4OT7fVa.d.ts} +1 -0
  8. package/dist/{analytics.modules-txdCLNG-.d.mts → analytics.modules-DEnXnRGt.d.mts} +1 -0
  9. package/dist/billing/index.js +492 -492
  10. package/dist/billing/index.mjs +4 -4
  11. package/dist/{chunk-I5BQLY7V.js → chunk-2CR7HWVS.js} +9 -9
  12. package/dist/{chunk-I5BQLY7V.js.map → chunk-2CR7HWVS.js.map} +1 -1
  13. package/dist/{chunk-B4ZH2FOC.js → chunk-2CXF5XBU.js} +906 -906
  14. package/dist/{chunk-B4ZH2FOC.js.map → chunk-2CXF5XBU.js.map} +1 -1
  15. package/dist/{chunk-KMPBSOFB.mjs → chunk-4GB35M62.mjs} +50 -2
  16. package/dist/chunk-4GB35M62.mjs.map +1 -0
  17. package/dist/{chunk-OO2DBP6L.js → chunk-CMNJENSJ.js} +942 -894
  18. package/dist/chunk-CMNJENSJ.js.map +1 -0
  19. package/dist/{chunk-OKBJEIOP.mjs → chunk-JSHLBRFV.mjs} +35 -6
  20. package/dist/chunk-JSHLBRFV.mjs.map +1 -0
  21. package/dist/{chunk-TY5YWBLL.mjs → chunk-QSNZ6HPY.mjs} +3 -3
  22. package/dist/{chunk-HEDHJAKT.mjs → chunk-QWQZUFFA.mjs} +6 -6
  23. package/dist/{chunk-DFA566KY.js → chunk-SFDOUTEV.js} +4 -4
  24. package/dist/{chunk-DFA566KY.js.map → chunk-SFDOUTEV.js.map} +1 -1
  25. package/dist/{chunk-YVKWACDP.mjs → chunk-WU3UXTDS.mjs} +2 -2
  26. package/dist/{chunk-YVKWACDP.mjs.map → chunk-WU3UXTDS.mjs.map} +1 -1
  27. package/dist/{chunk-A7Y5YW7Q.js → chunk-YWLSIIE6.js} +47 -18
  28. package/dist/chunk-YWLSIIE6.js.map +1 -0
  29. package/dist/client/index.js +5 -5
  30. package/dist/client/index.mjs +4 -4
  31. package/dist/components/index.js +5 -5
  32. package/dist/components/index.mjs +4 -4
  33. package/dist/contexts/index.js +5 -5
  34. package/dist/contexts/index.mjs +4 -4
  35. package/dist/core/index.d.mts +2 -1
  36. package/dist/core/index.d.ts +2 -1
  37. package/dist/core/index.js +3 -3
  38. package/dist/core/index.mjs +2 -2
  39. package/dist/email-automations/index.js +118 -118
  40. package/dist/email-automations/index.mjs +4 -4
  41. package/dist/features/analytics/index.d.mts +60 -3
  42. package/dist/features/analytics/index.d.ts +60 -3
  43. package/dist/features/analytics/index.js +320 -128
  44. package/dist/features/analytics/index.js.map +1 -1
  45. package/dist/features/analytics/index.mjs +314 -122
  46. package/dist/features/analytics/index.mjs.map +1 -1
  47. package/dist/features/analytics/server.js +5 -5
  48. package/dist/features/analytics/server.mjs +3 -3
  49. package/dist/features/blog/index.js +151 -151
  50. package/dist/features/blog/index.mjs +4 -4
  51. package/dist/features/blog/server.js +17 -17
  52. package/dist/features/blog/server.mjs +2 -2
  53. package/dist/features/help/index.js +38 -38
  54. package/dist/features/help/index.mjs +4 -4
  55. package/dist/features/tokenusage/index.js +127 -127
  56. package/dist/features/tokenusage/index.mjs +4 -4
  57. package/dist/index.d.mts +1 -1
  58. package/dist/index.d.ts +1 -1
  59. package/dist/index.js +4 -4
  60. package/dist/index.mjs +3 -3
  61. package/dist/offline/index.d.mts +7 -3
  62. package/dist/offline/index.d.ts +7 -3
  63. package/dist/offline/index.js +77 -32
  64. package/dist/offline/index.js.map +1 -1
  65. package/dist/offline/index.mjs +65 -20
  66. package/dist/offline/index.mjs.map +1 -1
  67. package/dist/server/index.d.mts +4 -3
  68. package/dist/server/index.d.ts +4 -3
  69. package/dist/server/index.js +4 -4
  70. package/dist/server/index.mjs +3 -3
  71. package/messages/en.json +8 -0
  72. package/messages/it.json +8 -0
  73. package/package.json +1 -1
  74. package/pwa/sw.js +17 -1
  75. package/src/core/factories/RehydrationFactory.ts +47 -5
  76. package/src/core/factories/__tests__/RehydrationFactory.spec.ts +78 -0
  77. package/src/core/registry/ModuleRegistry.ts +1 -0
  78. package/src/features/analytics/analytics.modules.ts +6 -0
  79. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +20 -0
  80. package/src/features/analytics/components/AnalyticsDailyUniquesChart.tsx +224 -0
  81. package/src/features/analytics/components/__tests__/AnalyticsDailyUniquesChart.spec.tsx +88 -0
  82. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +22 -0
  83. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +15 -2
  84. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +38 -0
  85. package/src/features/analytics/data/AnalyticsAdminService.ts +20 -0
  86. package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +15 -0
  87. package/src/features/analytics/data/__tests__/models.spec.ts +29 -0
  88. package/src/features/analytics/data/analytics-daily-unique-timeline.interface.ts +11 -0
  89. package/src/features/analytics/data/analytics-daily-unique-timeline.ts +53 -0
  90. package/src/features/analytics/data/analytics.types.ts +10 -0
  91. package/src/features/analytics/data/index.ts +2 -0
  92. package/src/features/analytics/i18n-keys.ts +8 -0
  93. package/src/features/analytics/index.ts +1 -0
  94. package/src/offline/server/build-offline-manifest.ts +4 -3
  95. package/src/offline/sync/__tests__/sync-root.spec.ts +88 -8
  96. package/src/offline/sync/sync-root.ts +79 -23
  97. package/src/pwa/__tests__/sw.spec.ts +33 -3
  98. package/dist/chunk-A7Y5YW7Q.js.map +0 -1
  99. package/dist/chunk-KMPBSOFB.mjs.map +0 -1
  100. package/dist/chunk-OKBJEIOP.mjs.map +0 -1
  101. package/dist/chunk-OO2DBP6L.js.map +0 -1
  102. /package/dist/{BlockNoteEditor-5HSL74IX.mjs.map → BlockNoteEditor-S7EVHYWT.mjs.map} +0 -0
  103. /package/dist/{BlockNoteViewer-X4UEVI2A.mjs.map → BlockNoteViewer-G3L46SGQ.mjs.map} +0 -0
  104. /package/dist/{chunk-TY5YWBLL.mjs.map → chunk-QSNZ6HPY.mjs.map} +0 -0
  105. /package/dist/{chunk-HEDHJAKT.mjs.map → chunk-QWQZUFFA.mjs.map} +0 -0
@@ -10,18 +10,58 @@ import { DataClassRegistry } from "../registry/DataClassRegistry";
10
10
  */
11
11
  const inProgress = new Set<string>();
12
12
 
13
+ /**
14
+ * The included resources already rehydrated during the current top-level `rehydrate` /
15
+ * `rehydrateList` call, by model class and `type:id`, with the `included` array they were read from.
16
+ * A resource reached again through another relationship reuses that model instead of being built
17
+ * again: without it every distinct path through a densely linked `included` (a campaign, its PCs,
18
+ * their locations, the scenes there, back to the campaign...) is rehydrated, which grows
19
+ * exponentially with the number of linked resources. A reused model keeps what it read the first
20
+ * time, so a relationship back to a resource that was mid-rehydration then stays unset.
21
+ * The top-level resources are always rehydrated from scratch, so each one is complete.
22
+ */
23
+ let built: Map<unknown, Map<string, { included: unknown; model: unknown }>> | undefined;
24
+ let depth = 0;
25
+
13
26
  const keyOf = (data: JsonApiHydratedDataInterface): string | undefined => {
14
27
  const resource = data?.jsonApi;
15
28
  return resource?.type && resource?.id ? `${resource.type}:${resource.id}` : undefined;
16
29
  };
17
30
 
18
- function guarded<T>(data: JsonApiHydratedDataInterface, run: () => T): T {
31
+ function session<T>(run: () => T): T {
32
+ const outermost = depth === 0;
33
+ if (outermost) built = new Map();
34
+ depth++;
35
+ try {
36
+ return run();
37
+ } finally {
38
+ depth--;
39
+ if (outermost) built = undefined;
40
+ }
41
+ }
42
+
43
+ function guarded<T>(factoryClass: unknown, data: JsonApiHydratedDataInterface, run: () => T): T {
19
44
  const key = keyOf(data);
20
45
  if (!key) return run();
21
46
  if (inProgress.has(key)) return undefined as T;
47
+
48
+ if (inProgress.size > 0) {
49
+ const previous = built?.get(factoryClass)?.get(key);
50
+ if (previous && previous.included === data.included) return previous.model as T;
51
+ }
52
+
22
53
  inProgress.add(key);
23
54
  try {
24
- return run();
55
+ const model = run();
56
+ if (built) {
57
+ let byKey = built.get(factoryClass);
58
+ if (!byKey) {
59
+ byKey = new Map();
60
+ built.set(factoryClass, byKey);
61
+ }
62
+ byKey.set(key, { included: data.included, model });
63
+ }
64
+ return model;
25
65
  } finally {
26
66
  inProgress.delete(key);
27
67
  }
@@ -34,7 +74,7 @@ export class RehydrationFactory {
34
74
  ): T {
35
75
  const factoryClass = DataClassRegistry.get(classKey);
36
76
 
37
- return guarded(data, () => new factoryClass().rehydrate(data) as T);
77
+ return session(() => guarded(factoryClass, data, () => new factoryClass().rehydrate(data) as T));
38
78
  }
39
79
 
40
80
  public static rehydrateList<T extends ApiDataInterface>(
@@ -43,8 +83,10 @@ export class RehydrationFactory {
43
83
  ): T[] {
44
84
  const factoryClass = DataClassRegistry.get(classKey);
45
85
 
46
- return data.map((item: JsonApiHydratedDataInterface) =>
47
- guarded(item, () => new factoryClass().rehydrate(item) as T),
86
+ return session(() =>
87
+ data.map((item: JsonApiHydratedDataInterface) =>
88
+ guarded(factoryClass, item, () => new factoryClass().rehydrate(item) as T),
89
+ ),
48
90
  );
49
91
  }
50
92
  }
@@ -182,4 +182,82 @@ describe("RehydrationFactory", () => {
182
182
  );
183
183
  });
184
184
  });
185
+
186
+ describe("a resource reached through several relationships", () => {
187
+ // A model that reads every resource its "links" relationship points to from included.
188
+ let calls = 0;
189
+ class Node implements Partial<ApiDataInterface> {
190
+ id = "";
191
+ links: Node[] = [];
192
+ rehydrate(data: JsonApiHydratedDataInterface) {
193
+ calls++;
194
+ this.id = data.jsonApi.id;
195
+ for (const ref of data.jsonApi.relationships?.links?.data ?? []) {
196
+ const target = data.included.find((item: any) => item.type === ref.type && item.id === ref.id);
197
+ if (!target) continue;
198
+ const node = RehydrationFactory.rehydrate<any>(nodeModule as any, { jsonApi: target, included: data.included });
199
+ if (node) this.links.push(node);
200
+ }
201
+ return this;
202
+ }
203
+ }
204
+ const nodeModule = { name: "nodes", model: Node };
205
+ const node = (id: string, links: string[]) => ({
206
+ type: "nodes",
207
+ id,
208
+ relationships: { links: { data: links.map((link) => ({ type: "nodes", id: link })) } },
209
+ });
210
+
211
+ beforeEach(() => {
212
+ calls = 0;
213
+ DataClassRegistry.registerObjectClass(nodeModule as any, Node as any);
214
+ });
215
+
216
+ it("is rehydrated once and the same model is reused", () => {
217
+ // root -> left, right; left -> shared; right -> shared
218
+ const included = [node("left", ["shared"]), node("right", ["shared"]), node("shared", [])];
219
+ const result = RehydrationFactory.rehydrate<any>(nodeModule as any, {
220
+ jsonApi: node("root", ["left", "right"]),
221
+ included,
222
+ });
223
+
224
+ expect(result.links[0].links[0].id).toBe("shared");
225
+ expect(result.links[0].links[0]).toBe(result.links[1].links[0]);
226
+ expect(calls).toBe(4);
227
+ });
228
+
229
+ it("densely linked included data is rehydrated in linear, not exponential, work", () => {
230
+ // Twelve resources, each linked to every other one: every path through them would be 11! chains.
231
+ const ids = Array.from({ length: 12 }, (_, index) => `n${index}`);
232
+ const included = ids.map((id) => node(id, ids.filter((other) => other !== id)));
233
+
234
+ const result = RehydrationFactory.rehydrate<any>(nodeModule as any, { jsonApi: included[0], included });
235
+
236
+ expect(result.links).toHaveLength(11);
237
+ expect(calls).toBe(12);
238
+ });
239
+
240
+ it("every top-level resource of a list is rehydrated whole, even one already reached as a relationship", () => {
241
+ // a -> b, b -> a: inside a, b's link back to a is skipped; b as its own list entry has it.
242
+ const a = node("a", ["b"]);
243
+ const b = node("b", ["a"]);
244
+ const [first, second] = RehydrationFactory.rehydrateList<any>(nodeModule as any, [
245
+ { jsonApi: a, included: [a, b] },
246
+ { jsonApi: b, included: [a, b] },
247
+ ]);
248
+
249
+ expect(first.links[0].id).toBe("b");
250
+ expect(first.links[0].links).toHaveLength(0);
251
+ expect(second.links[0].id).toBe("a");
252
+ expect(second).not.toBe(first.links[0]);
253
+ });
254
+
255
+ it("nothing is reused across separate calls", () => {
256
+ const included = [node("shared", [])];
257
+ const one = RehydrationFactory.rehydrate<any>(nodeModule as any, { jsonApi: node("x", ["shared"]), included });
258
+ const two = RehydrationFactory.rehydrate<any>(nodeModule as any, { jsonApi: node("y", ["shared"]), included });
259
+
260
+ expect(one.links[0]).not.toBe(two.links[0]);
261
+ });
262
+ });
185
263
  });
@@ -103,6 +103,7 @@ export interface FoundationModuleDefinitions {
103
103
  AnalyticsEvent: ModuleWithPermissions;
104
104
  AnalyticsSummary: ModuleWithPermissions;
105
105
  AnalyticsTimeline: ModuleWithPermissions;
106
+ AnalyticsDailyUniqueTimeline: ModuleWithPermissions;
106
107
  AnalyticsBreakdown: ModuleWithPermissions;
107
108
  AnalyticsSession: ModuleWithPermissions;
108
109
  AnalyticsPageView: ModuleWithPermissions;
@@ -1,6 +1,7 @@
1
1
  import { ChartNoAxesCombinedIcon } from "lucide-react";
2
2
  import { ModuleFactory, ModuleWithPermissions } from "../../permissions";
3
3
  import { AnalyticsBreakdown } from "./data/analytics-breakdown";
4
+ import { AnalyticsDailyUniqueTimeline } from "./data/analytics-daily-unique-timeline";
4
5
  import { AnalyticsEvent } from "./data/analytics-event";
5
6
  import { AnalyticsPageView } from "./data/analytics-page-view";
6
7
  import { AnalyticsSession } from "./data/analytics-session";
@@ -33,6 +34,11 @@ export const analyticsModules = (factory: ModuleFactory) =>
33
34
  model: AnalyticsTimeline,
34
35
  icon: ChartNoAxesCombinedIcon,
35
36
  }),
37
+ AnalyticsDailyUniqueTimeline: factory({
38
+ name: "analytics/administration/daily-uniques",
39
+ model: AnalyticsDailyUniqueTimeline,
40
+ icon: ChartNoAxesCombinedIcon,
41
+ }),
36
42
  AnalyticsBreakdown: factory({
37
43
  name: "analytics/administration/breakdown",
38
44
  model: AnalyticsBreakdown,
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../../shadcnui";
7
7
  import { useAnalyticsAdmin } from "../contexts/AnalyticsAdminContext";
8
8
  import { AnalyticsAdminTiles } from "./AnalyticsAdminTiles";
9
9
  import { AnalyticsBreakdownTable } from "./AnalyticsBreakdownTable";
10
+ import { AnalyticsDailyUniquesChart } from "./AnalyticsDailyUniquesChart";
10
11
  import { AnalyticsSessionsTable } from "./AnalyticsSessionsTable";
11
12
  import { AnalyticsTimelineChart } from "./AnalyticsTimelineChart";
12
13
 
@@ -25,6 +26,7 @@ export function AnalyticsAdminContainer() {
25
26
  timeline,
26
27
  breakdowns,
27
28
  sessions,
29
+ dailyUniques,
28
30
  loadMoreSessions,
29
31
  hasMoreSessions,
30
32
  sessionPageUrl,
@@ -70,6 +72,24 @@ export function AnalyticsAdminContainer() {
70
72
  </CardContent>
71
73
  </Card>
72
74
 
75
+ <Card data-testid="analytics-daily-visitors-card">
76
+ <CardHeader>
77
+ <CardTitle>{t("analytics.admin.panels.daily_visitors")}</CardTitle>
78
+ </CardHeader>
79
+ <CardContent>
80
+ <AnalyticsDailyUniquesChart rows={dailyUniques} metric="visitors" />
81
+ </CardContent>
82
+ </Card>
83
+
84
+ <Card data-testid="analytics-daily-users-card">
85
+ <CardHeader>
86
+ <CardTitle>{t("analytics.admin.panels.daily_users")}</CardTitle>
87
+ </CardHeader>
88
+ <CardContent>
89
+ <AnalyticsDailyUniquesChart rows={dailyUniques} metric="users" />
90
+ </CardContent>
91
+ </Card>
92
+
73
93
  <Card>
74
94
  <CardHeader>
75
95
  <CardTitle>{t("analytics.admin.panels.breakdown")}</CardTitle>
@@ -0,0 +1,224 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useTheme } from "next-themes";
5
+ import { useMemo } from "react";
6
+ import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
7
+ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../../../shadcnui";
8
+ import { useUsageFormatters } from "../../tokenusage/lib/formatters";
9
+ import { ChartMode, seriesColor } from "../../tokenusage/lib/palette";
10
+ import type { AnalyticsDailyUniqueTimelineInterface } from "../data/analytics-daily-unique-timeline.interface";
11
+
12
+ type DailyUniquesMetric = "visitors" | "users";
13
+
14
+ type AnalyticsDailyUniquesChartProps = {
15
+ rows: AnalyticsDailyUniqueTimelineInterface[];
16
+ metric: DailyUniquesMetric;
17
+ className?: string;
18
+ };
19
+
20
+ /** One chart entry per row. */
21
+ export type DailyUniquesEntry = {
22
+ day: string;
23
+ /** The row's value: null = unknown for that day. */
24
+ value: number | null;
25
+ /** The solid line. Null where the line has a gap or the dotted segment takes over. */
26
+ line: number | null;
27
+ /** The dotted yesterday-to-today segment; null everywhere else. */
28
+ todayLine: number | null;
29
+ isToday: boolean;
30
+ };
31
+
32
+ /**
33
+ * The palette slot of each metric: visitors keep the "Visitors" colour of the
34
+ * "Over time" chart (slot 0); users skip slot 1, which is page views there.
35
+ */
36
+ const METRIC_SLOT: Record<DailyUniquesMetric, number> = { visitors: 0, users: 2 };
37
+
38
+ /** Dot radius, solid and hollow alike. */
39
+ const DOT_RADIUS = 4;
40
+
41
+ /**
42
+ * The day key, derived with UTC getters — the same helper as
43
+ * `AnalyticsTimelineChart`. The backend field is `type: "date"`, which the model
44
+ * parses to UTC midnight, so local getters would shift it a day west of UTC.
45
+ */
46
+ const bucketKey = (date: Date): string => {
47
+ const y = date.getUTCFullYear();
48
+ const m = `${date.getUTCMonth() + 1}`.padStart(2, "0");
49
+ const d = `${date.getUTCDate()}`.padStart(2, "0");
50
+ return `${y}-${m}-${d}`;
51
+ };
52
+
53
+ /**
54
+ * Splits the rows into the solid line and the dotted "today" segment.
55
+ *
56
+ * Today (UTC) is still counting, so when the last row is today and both today
57
+ * and yesterday have values, the last segment moves to `todayLine` (drawn
58
+ * dotted) and the solid line stops at yesterday. A null value stays null in
59
+ * both series, which recharts draws as a gap (`connectNulls={false}`).
60
+ */
61
+ export function buildDailyUniquesSeries(
62
+ rows: AnalyticsDailyUniqueTimelineInterface[],
63
+ metric: DailyUniquesMetric,
64
+ todayKey: string,
65
+ ): DailyUniquesEntry[] {
66
+ const entries: DailyUniquesEntry[] = rows.map((row) => {
67
+ const day = bucketKey(row.day);
68
+ const value = row[metric] ?? null;
69
+ return { day, value, line: value, todayLine: null, isToday: day === todayKey };
70
+ });
71
+
72
+ const last = entries.length - 1;
73
+ if (last >= 1 && entries[last].isToday && entries[last].value !== null && entries[last - 1].value !== null) {
74
+ entries[last - 1].todayLine = entries[last - 1].value;
75
+ entries[last].todayLine = entries[last].value;
76
+ entries[last].line = null;
77
+ }
78
+
79
+ return entries;
80
+ }
81
+
82
+ type DotProps = { cx?: number | null; cy?: number | null; index?: number; payload?: DailyUniquesEntry };
83
+
84
+ /**
85
+ * Unique visitors or unique users per day, as a line.
86
+ *
87
+ * Always daily and never filtered by section: one row per day of the picked
88
+ * range, as the backend returns it. One series, so no legend — the card title
89
+ * names it. Colour does an identity job (the same metric keeps the same colour
90
+ * as in the "Over time" chart); the tooltip carries the value as text.
91
+ */
92
+ export function AnalyticsDailyUniquesChart({ rows, metric, className }: AnalyticsDailyUniquesChartProps) {
93
+ const t = useTranslations();
94
+ const { compact, bucketDate, decimal } = useUsageFormatters();
95
+ const { resolvedTheme } = useTheme();
96
+ const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light";
97
+ const color = seriesColor(METRIC_SLOT[metric], mode);
98
+
99
+ const chartData = useMemo(() => buildDailyUniquesSeries(rows, metric, bucketKey(new Date())), [rows, metric]);
100
+
101
+ const points = useMemo(() => chartData.map(({ day, value }) => ({ day, value })), [chartData]);
102
+
103
+ const seriesLabel = t(`analytics.admin.daily_uniques.${metric}`);
104
+
105
+ const chartConfig = useMemo(() => ({ line: { label: seriesLabel, color } }) as ChartConfig, [seriesLabel, color]);
106
+
107
+ if (chartData.length === 0) {
108
+ return (
109
+ <p className={className ? `text-muted-foreground text-xs ${className}` : "text-muted-foreground text-xs"}>
110
+ {t("analytics.admin.no_data")}
111
+ </p>
112
+ );
113
+ }
114
+
115
+ /** Small solid dots; today's point is hollow (still counting). */
116
+ const renderDot = ({ cx, cy, index, payload }: DotProps) => {
117
+ if (cx == null || cy == null || payload?.value == null) return <g key={`dot-${index}`} />;
118
+ return (
119
+ <circle
120
+ key={`dot-${index}`}
121
+ cx={cx}
122
+ cy={cy}
123
+ r={DOT_RADIUS}
124
+ fill={payload.isToday ? "var(--card)" : color}
125
+ stroke={payload.isToday ? color : "var(--card)"}
126
+ strokeWidth={2}
127
+ />
128
+ );
129
+ };
130
+
131
+ /** The dotted segment draws only today's hollow dot; yesterday's is on the solid line. */
132
+ const renderTodayDot = (props: DotProps) =>
133
+ props.payload?.isToday ? renderDot(props) : <g key={`today-dot-${props.index}`} />;
134
+
135
+ return (
136
+ // The chart is a coordinate system, not prose: it stays an LTR island.
137
+ // rtl-ok: deliberate LTR island (chart)
138
+ <div className={className} dir="ltr">
139
+ {/* The points, exposed for assertions and for screen readers that would
140
+ otherwise get nothing from the SVG. */}
141
+ <span className="sr-only" data-testid={`analytics-daily-${metric}-data`}>
142
+ {JSON.stringify(points)}
143
+ </span>
144
+
145
+ <ChartContainer config={chartConfig} className="aspect-auto h-72 w-full">
146
+ <LineChart accessibilityLayer data={chartData} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}>
147
+ <CartesianGrid vertical={false} />
148
+ <XAxis
149
+ dataKey="day"
150
+ tickLine={false}
151
+ axisLine={false}
152
+ tickMargin={8}
153
+ minTickGap={16}
154
+ tickFormatter={(value: string) => bucketDate(value, "day")}
155
+ />
156
+ <YAxis
157
+ domain={[0, "auto"]}
158
+ allowDecimals={false}
159
+ tickLine={false}
160
+ axisLine={false}
161
+ tickMargin={8}
162
+ width={48}
163
+ tickFormatter={(value: number) => compact(value)}
164
+ />
165
+ <ChartTooltip
166
+ // Keep a null day in the payload so the tooltip can say "no data".
167
+ filterNull={false}
168
+ content={
169
+ <ChartTooltipContent
170
+ labelFormatter={(value, payload) => {
171
+ const entry = payload?.[0]?.payload as DailyUniquesEntry | undefined;
172
+ return (
173
+ <>
174
+ {bucketDate(String(value), "day")}
175
+ {entry?.isToday && (
176
+ <div className="text-muted-foreground text-xs">{t("analytics.admin.daily_uniques.today")}</div>
177
+ )}
178
+ </>
179
+ );
180
+ }}
181
+ // The row's own value, not the hovered series': on today's
182
+ // point the solid line is null while the dotted one holds it.
183
+ formatter={(_value, _name, item) => {
184
+ const entry = item.payload as DailyUniquesEntry;
185
+ return (
186
+ <div className="flex flex-1 justify-between gap-4 leading-none">
187
+ <span className="text-muted-foreground">{seriesLabel}</span>
188
+ <span className="text-xs tabular-nums text-end">
189
+ {entry.value === null ? t("analytics.admin.daily_uniques.no_data") : decimal(entry.value, 0)}
190
+ </span>
191
+ </div>
192
+ );
193
+ }}
194
+ />
195
+ }
196
+ />
197
+ <Line
198
+ dataKey="line"
199
+ type="linear"
200
+ stroke={color}
201
+ strokeWidth={2}
202
+ connectNulls={false}
203
+ dot={renderDot}
204
+ activeDot={{ r: DOT_RADIUS, strokeWidth: 2, stroke: "var(--card)" }}
205
+ isAnimationActive={false}
206
+ />
207
+ <Line
208
+ dataKey="todayLine"
209
+ type="linear"
210
+ stroke={color}
211
+ strokeWidth={2}
212
+ strokeDasharray="2 5"
213
+ connectNulls={false}
214
+ dot={renderTodayDot}
215
+ activeDot={false}
216
+ tooltipType="none"
217
+ legendType="none"
218
+ isAnimationActive={false}
219
+ />
220
+ </LineChart>
221
+ </ChartContainer>
222
+ </div>
223
+ );
224
+ }
@@ -0,0 +1,88 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { AnalyticsDailyUniquesChart, buildDailyUniquesSeries } from "../AnalyticsDailyUniquesChart";
4
+
5
+ const row = (day: string, visitors: number | null, users: number | null) =>
6
+ ({
7
+ id: day,
8
+ day: new Date(day),
9
+ visitors,
10
+ users,
11
+ }) as any;
12
+
13
+ const ROWS = [row("2026-11-01", 5, 2), row("2026-11-02", 3, null), row("2026-11-03", 0, 0), row("2026-11-04", 4, 1)];
14
+
15
+ describe("AnalyticsDailyUniquesChart", () => {
16
+ beforeEach(() => {
17
+ vi.useFakeTimers({ toFake: ["Date"] });
18
+ vi.setSystemTime(new Date("2026-11-04T10:00:00Z"));
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.useRealTimers();
23
+ });
24
+
25
+ it("exposes one visitors point per row, in order", () => {
26
+ render(<AnalyticsDailyUniquesChart rows={ROWS} metric="visitors" />);
27
+
28
+ const data = JSON.parse(screen.getByTestId("analytics-daily-visitors-data").textContent!);
29
+ expect(data).toEqual([
30
+ { day: "2026-11-01", value: 5 },
31
+ { day: "2026-11-02", value: 3 },
32
+ { day: "2026-11-03", value: 0 },
33
+ { day: "2026-11-04", value: 4 },
34
+ ]);
35
+ });
36
+
37
+ it("exposes one users point per row, keeping null as null and 0 as 0", () => {
38
+ render(<AnalyticsDailyUniquesChart rows={ROWS} metric="users" />);
39
+
40
+ const data = JSON.parse(screen.getByTestId("analytics-daily-users-data").textContent!);
41
+ expect(data).toEqual([
42
+ { day: "2026-11-01", value: 2 },
43
+ { day: "2026-11-02", value: null },
44
+ { day: "2026-11-03", value: 0 },
45
+ { day: "2026-11-04", value: 1 },
46
+ ]);
47
+ });
48
+
49
+ it("renders the empty state without a data node when there are no rows", () => {
50
+ render(<AnalyticsDailyUniquesChart rows={[]} metric="visitors" />);
51
+
52
+ expect(screen.queryByTestId("analytics-daily-visitors-data")).not.toBeInTheDocument();
53
+ expect(screen.getByText("analytics.admin.no_data")).toBeInTheDocument();
54
+ });
55
+
56
+ it("draws the chart, not the empty state, when every day is zero", () => {
57
+ render(<AnalyticsDailyUniquesChart rows={[row("2026-11-03", 0, 0), row("2026-11-04", 0, 0)]} metric="users" />);
58
+
59
+ expect(screen.getByTestId("analytics-daily-users-data")).toBeInTheDocument();
60
+ expect(screen.queryByText("analytics.admin.no_data")).not.toBeInTheDocument();
61
+ });
62
+ });
63
+
64
+ describe("buildDailyUniquesSeries", () => {
65
+ it("moves the yesterday-to-today segment onto the dotted series when both days have values", () => {
66
+ const series = buildDailyUniquesSeries(ROWS, "visitors", "2026-11-04");
67
+
68
+ expect(series.map((entry) => entry.line)).toEqual([5, 3, 0, null]);
69
+ expect(series.map((entry) => entry.todayLine)).toEqual([null, null, 0, 4]);
70
+ expect(series.map((entry) => entry.isToday)).toEqual([false, false, false, true]);
71
+ });
72
+
73
+ it("draws no dotted segment when yesterday is unknown, and leaves null days as gaps", () => {
74
+ const rows = [row("2026-11-03", 2, null), row("2026-11-04", 4, 1)];
75
+ const series = buildDailyUniquesSeries(rows, "users", "2026-11-04");
76
+
77
+ expect(series.map((entry) => entry.line)).toEqual([null, 1]);
78
+ expect(series.map((entry) => entry.todayLine)).toEqual([null, null]);
79
+ expect(series[1].isToday).toBe(true);
80
+ });
81
+
82
+ it("draws no dotted segment when the range ends before today", () => {
83
+ const series = buildDailyUniquesSeries(ROWS.slice(0, 3), "visitors", "2026-11-04");
84
+
85
+ expect(series.map((entry) => entry.line)).toEqual([5, 3, 0]);
86
+ expect(series.every((entry) => entry.todayLine === null && !entry.isToday)).toBe(true);
87
+ });
88
+ });
@@ -11,6 +11,7 @@ vi.mock("../../data/AnalyticsAdminService", () => ({
11
11
  getTimeline: vi.fn(),
12
12
  getBreakdown: vi.fn(),
13
13
  getSessions: vi.fn(),
14
+ getDailyUniques: vi.fn(),
14
15
  getSession: vi.fn(),
15
16
  getSessionPageViews: vi.fn(),
16
17
  next: vi.fn(),
@@ -89,6 +90,27 @@ describe("AnalyticsAdminPageContainer", () => {
89
90
  vi.mocked(AnalyticsAdminService.getTimeline).mockResolvedValue([]);
90
91
  vi.mocked(AnalyticsAdminService.getBreakdown).mockResolvedValue([]);
91
92
  vi.mocked(AnalyticsAdminService.getSessions).mockResolvedValue([session("s1", "2026-10-01T10:00:00.000Z")]);
93
+ vi.mocked(AnalyticsAdminService.getDailyUniques).mockResolvedValue([]);
94
+ });
95
+
96
+ it("renders the daily visitors and users cards between Over time and Breakdown", async () => {
97
+ render(<AnalyticsAdminPageContainer />);
98
+
99
+ const visitorsCard = await screen.findByTestId("analytics-daily-visitors-card");
100
+ const usersCard = screen.getByTestId("analytics-daily-users-card");
101
+ expect(visitorsCard).toHaveTextContent("analytics.admin.panels.daily_visitors");
102
+ expect(usersCard).toHaveTextContent("analytics.admin.panels.daily_users");
103
+
104
+ const overTime = screen.getByText("analytics.admin.panels.over_time");
105
+ const breakdown = screen.getByText("analytics.admin.panels.breakdown");
106
+ const follows = (a: Node, b: Node) => Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
107
+
108
+ expect(follows(overTime, visitorsCard)).toBe(true);
109
+ expect(follows(visitorsCard, usersCard)).toBe(true);
110
+ expect(follows(usersCard, breakdown)).toBe(true);
111
+ expect(overTime.closest('[data-slot="card"]')?.nextElementSibling).toBe(visitorsCard);
112
+ expect(visitorsCard.nextElementSibling).toBe(usersCard);
113
+ expect(usersCard.nextElementSibling).toBe(breakdown.closest('[data-slot="card"]'));
92
114
  });
93
115
 
94
116
  it("loads the dashboard and links each session under the given sessionPageUrl", async () => {
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
4
  import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
5
- import { Modules, NextRef } from "../../../core";
5
+ import { formatLocalDate, Modules, NextRef } from "../../../core";
6
6
  import { SharedProvider } from "../../../contexts";
7
7
  import { BreadcrumbItemData } from "../../../interfaces";
8
8
  import { AnalyticsAdminFilterBar } from "../components/AnalyticsAdminFilterBar";
9
9
  import type { AnalyticsBreakdownInterface } from "../data/analytics-breakdown.interface";
10
+ import type { AnalyticsDailyUniqueTimelineInterface } from "../data/analytics-daily-unique-timeline.interface";
10
11
  import type { AnalyticsSessionInterface } from "../data/analytics-session.interface";
11
12
  import type { AnalyticsSummaryInterface } from "../data/analytics-summary.interface";
12
13
  import type { AnalyticsTimelineInterface } from "../data/analytics-timeline.interface";
@@ -51,6 +52,8 @@ export interface AnalyticsAdminContextType {
51
52
  timeline: AnalyticsTimelineInterface[];
52
53
  breakdowns: Record<AnalyticsDimension, AnalyticsBreakdownInterface[]>;
53
54
  sessions: AnalyticsSessionInterface[];
55
+ /** One row per day of the range: unique visitors and users, never filtered by section. */
56
+ dailyUniques: AnalyticsDailyUniqueTimelineInterface[];
54
57
  filters: AnalyticsAdminFilterState;
55
58
  /** Merges a partial patch into the current filters; every key is optional. */
56
59
  setFilters: (next: Partial<AnalyticsAdminFilterState>) => void;
@@ -120,6 +123,7 @@ export const AnalyticsAdminProvider = ({
120
123
  const [breakdowns, setBreakdowns] =
121
124
  useState<Record<AnalyticsDimension, AnalyticsBreakdownInterface[]>>(EMPTY_BREAKDOWNS);
122
125
  const [sessions, setSessions] = useState<AnalyticsSessionInterface[]>([]);
126
+ const [dailyUniques, setDailyUniques] = useState<AnalyticsDailyUniqueTimelineInterface[]>([]);
123
127
  /** The cursor URL of the next sessions page, as the API returned it. */
124
128
  const [sessionsNextPage, setSessionsNextPage] = useState<string | undefined>(undefined);
125
129
  const [isLoading, setIsLoading] = useState(true);
@@ -149,8 +153,14 @@ export const AnalyticsAdminProvider = ({
149
153
  ),
150
154
  ),
151
155
  AnalyticsAdminService.getSessions({ ...base, next: sessionsNext }),
156
+ // The local calendar days the admin picked: the picker emits local-midnight
157
+ // instants, so slicing the UTC instant would shift the range a day east of UTC.
158
+ AnalyticsAdminService.getDailyUniques({
159
+ fromDate: formatLocalDate(new Date(from)),
160
+ toDate: formatLocalDate(new Date(to)),
161
+ }),
152
162
  ])
153
- .then(([nextSummary, nextTimeline, nextBreakdowns, nextSessions]) => {
163
+ .then(([nextSummary, nextTimeline, nextBreakdowns, nextSessions, nextDailyUniques]) => {
154
164
  if (cancelled) return;
155
165
  setSummary(nextSummary ?? []);
156
166
  setTimeline(nextTimeline ?? []);
@@ -161,6 +171,7 @@ export const AnalyticsAdminProvider = ({
161
171
  );
162
172
  setSessions(nextSessions ?? []);
163
173
  setSessionsNextPage(sessionsNext.next);
174
+ setDailyUniques(nextDailyUniques ?? []);
164
175
  })
165
176
  .catch((err) => {
166
177
  if (cancelled) return;
@@ -225,6 +236,7 @@ export const AnalyticsAdminProvider = ({
225
236
  timeline,
226
237
  breakdowns,
227
238
  sessions,
239
+ dailyUniques,
228
240
  filters,
229
241
  setFilters,
230
242
  loadMoreSessions,
@@ -238,6 +250,7 @@ export const AnalyticsAdminProvider = ({
238
250
  timeline,
239
251
  breakdowns,
240
252
  sessions,
253
+ dailyUniques,
241
254
  filters,
242
255
  setFilters,
243
256
  loadMoreSessions,