@carlonicora/nextjs-jsonapi 6.19.0 → 6.20.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 (91) hide show
  1. package/dist/{BlockNoteEditor-5HSL74IX.mjs → BlockNoteEditor-7JRJ3XWV.mjs} +5 -5
  2. package/dist/{BlockNoteEditor-6RZXGMSD.js → BlockNoteEditor-MH2D5L4A.js} +20 -20
  3. package/dist/{BlockNoteEditor-6RZXGMSD.js.map → BlockNoteEditor-MH2D5L4A.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-XYKFY77F.js → BlockNoteViewer-72J3CBAH.js} +4 -4
  5. package/dist/{BlockNoteViewer-XYKFY77F.js.map → BlockNoteViewer-72J3CBAH.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-X4UEVI2A.mjs → BlockNoteViewer-UKYEU3E3.mjs} +3 -3
  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-OKBJEIOP.mjs → chunk-2U57XVNF.mjs} +1 -1
  12. package/dist/chunk-2U57XVNF.mjs.map +1 -0
  13. package/dist/{chunk-KMPBSOFB.mjs → chunk-ASORDSWQ.mjs} +50 -2
  14. package/dist/chunk-ASORDSWQ.mjs.map +1 -0
  15. package/dist/{chunk-B4ZH2FOC.js → chunk-IN2F6HI2.js} +906 -906
  16. package/dist/{chunk-B4ZH2FOC.js.map → chunk-IN2F6HI2.js.map} +1 -1
  17. package/dist/{chunk-DFA566KY.js → chunk-JAOBCIE7.js} +4 -4
  18. package/dist/{chunk-DFA566KY.js.map → chunk-JAOBCIE7.js.map} +1 -1
  19. package/dist/{chunk-YVKWACDP.mjs → chunk-LSZZIQJ5.mjs} +2 -2
  20. package/dist/{chunk-I5BQLY7V.js → chunk-MVAIYPIN.js} +9 -9
  21. package/dist/{chunk-I5BQLY7V.js.map → chunk-MVAIYPIN.js.map} +1 -1
  22. package/dist/{chunk-HEDHJAKT.mjs → chunk-NKXNOGMH.mjs} +6 -6
  23. package/dist/{chunk-OO2DBP6L.js → chunk-SGLFQ55U.js} +942 -894
  24. package/dist/chunk-SGLFQ55U.js.map +1 -0
  25. package/dist/{chunk-TY5YWBLL.mjs → chunk-SJ3K3ATF.mjs} +3 -3
  26. package/dist/{chunk-A7Y5YW7Q.js → chunk-V7CHD7QT.js} +1 -1
  27. package/dist/{chunk-A7Y5YW7Q.js.map → chunk-V7CHD7QT.js.map} +1 -1
  28. package/dist/client/index.js +5 -5
  29. package/dist/client/index.mjs +4 -4
  30. package/dist/components/index.js +5 -5
  31. package/dist/components/index.mjs +4 -4
  32. package/dist/contexts/index.js +5 -5
  33. package/dist/contexts/index.mjs +4 -4
  34. package/dist/core/index.d.mts +2 -1
  35. package/dist/core/index.d.ts +2 -1
  36. package/dist/core/index.js +3 -3
  37. package/dist/core/index.mjs +2 -2
  38. package/dist/email-automations/index.js +118 -118
  39. package/dist/email-automations/index.mjs +4 -4
  40. package/dist/features/analytics/index.d.mts +60 -3
  41. package/dist/features/analytics/index.d.ts +60 -3
  42. package/dist/features/analytics/index.js +320 -128
  43. package/dist/features/analytics/index.js.map +1 -1
  44. package/dist/features/analytics/index.mjs +314 -122
  45. package/dist/features/analytics/index.mjs.map +1 -1
  46. package/dist/features/analytics/server.js +5 -5
  47. package/dist/features/analytics/server.mjs +3 -3
  48. package/dist/features/blog/index.js +151 -151
  49. package/dist/features/blog/index.mjs +4 -4
  50. package/dist/features/blog/server.js +17 -17
  51. package/dist/features/blog/server.mjs +2 -2
  52. package/dist/features/help/index.js +38 -38
  53. package/dist/features/help/index.mjs +4 -4
  54. package/dist/features/tokenusage/index.js +127 -127
  55. package/dist/features/tokenusage/index.mjs +4 -4
  56. package/dist/index.d.mts +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +4 -4
  59. package/dist/index.mjs +3 -3
  60. package/dist/offline/index.js +14 -14
  61. package/dist/offline/index.mjs +1 -1
  62. package/dist/server/index.js +4 -4
  63. package/dist/server/index.mjs +3 -3
  64. package/messages/en.json +8 -0
  65. package/messages/it.json +8 -0
  66. package/package.json +1 -1
  67. package/src/core/registry/ModuleRegistry.ts +1 -0
  68. package/src/features/analytics/analytics.modules.ts +6 -0
  69. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +20 -0
  70. package/src/features/analytics/components/AnalyticsDailyUniquesChart.tsx +224 -0
  71. package/src/features/analytics/components/__tests__/AnalyticsDailyUniquesChart.spec.tsx +88 -0
  72. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +22 -0
  73. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +15 -2
  74. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +38 -0
  75. package/src/features/analytics/data/AnalyticsAdminService.ts +20 -0
  76. package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +15 -0
  77. package/src/features/analytics/data/__tests__/models.spec.ts +29 -0
  78. package/src/features/analytics/data/analytics-daily-unique-timeline.interface.ts +11 -0
  79. package/src/features/analytics/data/analytics-daily-unique-timeline.ts +53 -0
  80. package/src/features/analytics/data/analytics.types.ts +10 -0
  81. package/src/features/analytics/data/index.ts +2 -0
  82. package/src/features/analytics/i18n-keys.ts +8 -0
  83. package/src/features/analytics/index.ts +1 -0
  84. package/dist/chunk-KMPBSOFB.mjs.map +0 -1
  85. package/dist/chunk-OKBJEIOP.mjs.map +0 -1
  86. package/dist/chunk-OO2DBP6L.js.map +0 -1
  87. /package/dist/{BlockNoteEditor-5HSL74IX.mjs.map → BlockNoteEditor-7JRJ3XWV.mjs.map} +0 -0
  88. /package/dist/{BlockNoteViewer-X4UEVI2A.mjs.map → BlockNoteViewer-UKYEU3E3.mjs.map} +0 -0
  89. /package/dist/{chunk-YVKWACDP.mjs.map → chunk-LSZZIQJ5.mjs.map} +0 -0
  90. /package/dist/{chunk-HEDHJAKT.mjs.map → chunk-NKXNOGMH.mjs.map} +0 -0
  91. /package/dist/{chunk-TY5YWBLL.mjs.map → chunk-SJ3K3ATF.mjs.map} +0 -0
@@ -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,
@@ -1,6 +1,7 @@
1
1
  import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { useSharedContext } from "../../../../contexts/SharedContext";
4
+ import { formatLocalDate } from "../../../../core";
4
5
  import { AnalyticsAdminService } from "../../data/AnalyticsAdminService";
5
6
  import { AnalyticsAdminProvider, useAnalyticsAdmin } from "../AnalyticsAdminContext";
6
7
 
@@ -10,6 +11,7 @@ vi.mock("../../data/AnalyticsAdminService", () => ({
10
11
  getTimeline: vi.fn(async () => []),
11
12
  getBreakdown: vi.fn(async () => []),
12
13
  getSessions: vi.fn(async () => []),
14
+ getDailyUniques: vi.fn(async () => []),
13
15
  next: vi.fn(async () => []),
14
16
  },
15
17
  }));
@@ -31,6 +33,8 @@ function Probe() {
31
33
  <span data-testid="sessions">{ctx.sessions.map((s) => s.id).join(",")}</span>
32
34
  <span data-testid="has-more">{String(ctx.hasMoreSessions)}</span>
33
35
  <span data-testid="section">{ctx.filters.section}</span>
36
+ <span data-testid="daily-uniques">{ctx.dailyUniques.map((d) => d.id).join(",")}</span>
37
+ <span data-testid="error">{ctx.error ?? ""}</span>
34
38
  <button type="button" onClick={() => ctx.setFilters({ section: "app" })}>
35
39
  app
36
40
  </button>
@@ -86,6 +90,40 @@ describe("AnalyticsAdminContext", () => {
86
90
  expect(breakdowns).toHaveLength(6);
87
91
  });
88
92
 
93
+ it("requests the daily uniques once with the local calendar dates of the range and exposes them", async () => {
94
+ vi.mocked(AnalyticsAdminService.getDailyUniques).mockResolvedValueOnce([
95
+ { id: "2026-11-03" },
96
+ { id: "2026-11-04" },
97
+ ] as any);
98
+
99
+ render(
100
+ <AnalyticsAdminProvider>
101
+ <Probe />
102
+ </AnalyticsAdminProvider>,
103
+ );
104
+
105
+ await waitFor(() => expect(screen.getByTestId("daily-uniques")).toHaveTextContent("2026-11-03,2026-11-04"));
106
+
107
+ expect(AnalyticsAdminService.getDailyUniques).toHaveBeenCalledTimes(1);
108
+ const { from, to } = vi.mocked(AnalyticsAdminService.getSummary).mock.calls[0][0];
109
+ expect(AnalyticsAdminService.getDailyUniques).toHaveBeenCalledWith({
110
+ fromDate: formatLocalDate(new Date(from)),
111
+ toDate: formatLocalDate(new Date(to)),
112
+ });
113
+ });
114
+
115
+ it("sets the page error when the daily uniques request fails", async () => {
116
+ vi.mocked(AnalyticsAdminService.getDailyUniques).mockRejectedValueOnce(new Error("daily uniques failed"));
117
+
118
+ render(
119
+ <AnalyticsAdminProvider>
120
+ <Probe />
121
+ </AnalyticsAdminProvider>,
122
+ );
123
+
124
+ await waitFor(() => expect(screen.getByTestId("error")).toHaveTextContent("daily uniques failed"));
125
+ });
126
+
89
127
  it("defaults to the last 30 days", async () => {
90
128
  render(
91
129
  <AnalyticsAdminProvider>
@@ -1,5 +1,6 @@
1
1
  import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef } from "../../../core";
2
2
  import { AnalyticsBreakdownInterface } from "./analytics-breakdown.interface";
3
+ import { AnalyticsDailyUniqueTimelineInterface } from "./analytics-daily-unique-timeline.interface";
3
4
  import { AnalyticsPageViewInterface } from "./analytics-page-view.interface";
4
5
  import { AnalyticsSessionInterface } from "./analytics-session.interface";
5
6
  import { AnalyticsSummaryInterface } from "./analytics-summary.interface";
@@ -38,6 +39,25 @@ export class AnalyticsAdminService extends AbstractService {
38
39
  });
39
40
  }
40
41
 
42
+ /**
43
+ * One row per calendar day from fromDate to toDate (YYYY-MM-DD, local dates).
44
+ * Always daily and never filtered by section, so it skips withFilters.
45
+ */
46
+ static async getDailyUniques(params: {
47
+ fromDate: string;
48
+ toDate: string;
49
+ }): Promise<AnalyticsDailyUniqueTimelineInterface[]> {
50
+ const endpoint = new EndpointCreator({ endpoint: Modules.AnalyticsDailyUniqueTimeline });
51
+ endpoint.addAdditionalParam("fromDate", params.fromDate);
52
+ endpoint.addAdditionalParam("toDate", params.toDate);
53
+
54
+ return this.callApi<AnalyticsDailyUniqueTimelineInterface[]>({
55
+ type: Modules.AnalyticsDailyUniqueTimeline,
56
+ method: HttpMethod.GET,
57
+ endpoint: endpoint.generate(),
58
+ });
59
+ }
60
+
41
61
  static async getBreakdown(
42
62
  params: AnalyticsFilters & { dimension: AnalyticsDimension; limit?: number },
43
63
  ): Promise<AnalyticsBreakdownInterface[]> {
@@ -114,4 +114,19 @@ describe("AnalyticsAdminService", () => {
114
114
  expect(call.method).toBe("GET");
115
115
  expect(call.endpoint).toBe("analytics/administration/sessions/s1/pageviews");
116
116
  });
117
+ it("getDailyUniques sends only fromDate and toDate to the daily-uniques endpoint, never the section filters", async () => {
118
+ const spy = callApi();
119
+
120
+ await AnalyticsAdminService.getDailyUniques({ fromDate: "2026-10-05", toDate: "2026-11-04" });
121
+
122
+ const call = firstCall(spy);
123
+ expect(call.type).toBe(ModuleRegistry.get("AnalyticsDailyUniqueTimeline" as any));
124
+ expect(call.method).toBe("GET");
125
+ expect(call.endpoint).toContain("analytics/administration/daily-uniques");
126
+ expect(call.endpoint).toContain("fromDate=2026-10-05");
127
+ expect(call.endpoint).toContain("toDate=2026-11-04");
128
+ expect(call.endpoint).not.toContain("section");
129
+ expect(call.endpoint).not.toMatch(/[?&]from=/);
130
+ expect(call.endpoint).not.toMatch(/[?&]to=/);
131
+ });
117
132
  });
@@ -7,6 +7,7 @@ import { AbstractApiData } from "../../../../core/abstracts/AbstractApiData";
7
7
  import { JsonApiHydratedDataInterface } from "../../../../core/interfaces/JsonApiHydratedDataInterface";
8
8
  import { analyticsModules } from "../../analytics.modules";
9
9
  import { AnalyticsBreakdown } from "../analytics-breakdown";
10
+ import { AnalyticsDailyUniqueTimeline } from "../analytics-daily-unique-timeline";
10
11
  import { AnalyticsEvent } from "../analytics-event";
11
12
  import { AnalyticsPageView } from "../analytics-page-view";
12
13
  import { AnalyticsSession } from "../analytics-session";
@@ -89,6 +90,26 @@ describe("analytics models — rehydrate", () => {
89
90
  expect(m.pageViews).toBe(3);
90
91
  });
91
92
 
93
+ it("rehydrates a daily-unique row: the day into a UTC-midnight Date and both counts", () => {
94
+ const m = new AnalyticsDailyUniqueTimeline().rehydrate(hydrate({ day: "2026-11-03", visitors: 3, users: 1 }));
95
+ expect(m.day).toBeInstanceOf(Date);
96
+ expect(m.day.getTime()).toBe(new Date("2026-11-03").getTime());
97
+ expect(m.visitors).toBe(3);
98
+ expect(m.users).toBe(1);
99
+ });
100
+
101
+ it("rehydrates an unknown daily-unique count to null, whether sent as null or missing", () => {
102
+ const m = new AnalyticsDailyUniqueTimeline().rehydrate(hydrate({ day: "2026-11-03", visitors: null }));
103
+ expect(m.visitors).toBeNull();
104
+ expect(m.users).toBeNull();
105
+ });
106
+
107
+ it("keeps a zero daily-unique count as 0, not null", () => {
108
+ const m = new AnalyticsDailyUniqueTimeline().rehydrate(hydrate({ day: "2026-11-03", visitors: 0, users: 0 }));
109
+ expect(m.visitors).toBe(0);
110
+ expect(m.users).toBe(0);
111
+ });
112
+
92
113
  it("rehydrates every breakdown attribute", () => {
93
114
  const m = new AnalyticsBreakdown().rehydrate(
94
115
  hydrate({ dimension: "source", key: "linkedin", visitors: 4, sessions: 5, pageViews: 6 }),
@@ -254,6 +275,14 @@ describe("analytics models — createJsonApi", () => {
254
275
  expect(JSON.parse(JSON.stringify(doc)).data.attributes.bucket).toBe("2026-09-01");
255
276
  });
256
277
 
278
+ it("emits the daily-unique day as YYYY-MM-DD via formatLocalDate, never a raw Date", () => {
279
+ const doc = new AnalyticsDailyUniqueTimeline().createJsonApi({ id: "2026-11-03", day: new Date(2026, 10, 3) });
280
+ expect(doc.data.type).toBe("analytics/administration/daily-uniques");
281
+ expect(doc.data.id).toBe("2026-11-03");
282
+ expect(doc.data.attributes.day).toBe("2026-11-03");
283
+ expect(JSON.parse(JSON.stringify(doc)).data.attributes.day).toBe("2026-11-03");
284
+ });
285
+
257
286
  it("serialises a breakdown row", () => {
258
287
  const doc = new AnalyticsBreakdown().createJsonApi({
259
288
  id: "source|linkedin",
@@ -0,0 +1,11 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ /** One calendar day of the unique visitors / unique users charts. */
4
+ export interface AnalyticsDailyUniqueTimelineInterface extends ApiDataInterface {
5
+ /** Backend declares this `type: "date"`, so it is a Date in memory. */
6
+ get day(): Date;
7
+ /** null = unknown for that day (drawn as a gap). */
8
+ get visitors(): number | null;
9
+ /** null = unknown for that day (drawn as a gap). */
10
+ get users(): number | null;
11
+ }
@@ -0,0 +1,53 @@
1
+ import { AbstractApiData, formatLocalDate, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { AnalyticsDailyUniqueTimelineInterface } from "./analytics-daily-unique-timeline.interface";
3
+ import { AnalyticsDailyUniqueTimelineInput } from "./analytics.types";
4
+
5
+ /** One day of the daily unique visitors / users charts. A null count is an unknown day, never 0. */
6
+ export class AnalyticsDailyUniqueTimeline extends AbstractApiData implements AnalyticsDailyUniqueTimelineInterface {
7
+ private _day: Date = new Date(0);
8
+ private _visitors: number | null = null;
9
+ private _users: number | null = null;
10
+
11
+ get day(): Date {
12
+ return this._day;
13
+ }
14
+
15
+ get visitors(): number | null {
16
+ return this._visitors;
17
+ }
18
+
19
+ get users(): number | null {
20
+ return this._users;
21
+ }
22
+
23
+ rehydrate(data: JsonApiHydratedDataInterface): this {
24
+ super.rehydrate(data);
25
+
26
+ const attrs = data.jsonApi.attributes;
27
+ // Wire format is "YYYY-MM-DD" (always present); the interface promises a Date.
28
+ this._day = new Date(attrs.day);
29
+ this._visitors = attrs.visitors ?? null;
30
+ this._users = attrs.users ?? null;
31
+
32
+ return this;
33
+ }
34
+
35
+ createJsonApi(data: AnalyticsDailyUniqueTimelineInput) {
36
+ const response: any = {
37
+ data: {
38
+ type: Modules.AnalyticsDailyUniqueTimeline.name,
39
+ id: data.id,
40
+ attributes: {},
41
+ relationships: {},
42
+ },
43
+ included: [],
44
+ };
45
+
46
+ // `day` is type: "date" on the backend descriptor: formatLocalDate, never the raw Date.
47
+ if (data.day !== undefined) response.data.attributes.day = formatLocalDate(data.day);
48
+ if (data.visitors !== undefined) response.data.attributes.visitors = data.visitors;
49
+ if (data.users !== undefined) response.data.attributes.users = data.users;
50
+
51
+ return response;
52
+ }
53
+ }
@@ -59,6 +59,16 @@ export type AnalyticsTimelineInput = {
59
59
  pageViews: number;
60
60
  };
61
61
 
62
+ export type AnalyticsDailyUniqueTimelineInput = {
63
+ id: string;
64
+ /** Backend field type is "date" — emitted via formatLocalDate, never raw. */
65
+ day?: Date;
66
+ /** null = unknown for that day. */
67
+ visitors?: number | null;
68
+ /** null = unknown for that day. */
69
+ users?: number | null;
70
+ };
71
+
62
72
  export type AnalyticsBreakdownInput = {
63
73
  id: string;
64
74
  dimension: string;