@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.
- package/dist/{BlockNoteEditor-5HSL74IX.mjs → BlockNoteEditor-S7EVHYWT.mjs} +5 -5
- package/dist/{BlockNoteEditor-6RZXGMSD.js → BlockNoteEditor-V76V5XFY.js} +20 -20
- package/dist/{BlockNoteEditor-6RZXGMSD.js.map → BlockNoteEditor-V76V5XFY.js.map} +1 -1
- package/dist/{BlockNoteViewer-X4UEVI2A.mjs → BlockNoteViewer-G3L46SGQ.mjs} +3 -3
- package/dist/{BlockNoteViewer-XYKFY77F.js → BlockNoteViewer-ZFJ3DZXN.js} +4 -4
- package/dist/{BlockNoteViewer-XYKFY77F.js.map → BlockNoteViewer-ZFJ3DZXN.js.map} +1 -1
- package/dist/{analytics.modules-Bsacu6ik.d.ts → analytics.modules-C4OT7fVa.d.ts} +1 -0
- package/dist/{analytics.modules-txdCLNG-.d.mts → analytics.modules-DEnXnRGt.d.mts} +1 -0
- package/dist/billing/index.js +492 -492
- package/dist/billing/index.mjs +4 -4
- package/dist/{chunk-I5BQLY7V.js → chunk-2CR7HWVS.js} +9 -9
- package/dist/{chunk-I5BQLY7V.js.map → chunk-2CR7HWVS.js.map} +1 -1
- package/dist/{chunk-B4ZH2FOC.js → chunk-2CXF5XBU.js} +906 -906
- package/dist/{chunk-B4ZH2FOC.js.map → chunk-2CXF5XBU.js.map} +1 -1
- package/dist/{chunk-KMPBSOFB.mjs → chunk-4GB35M62.mjs} +50 -2
- package/dist/chunk-4GB35M62.mjs.map +1 -0
- package/dist/{chunk-OO2DBP6L.js → chunk-CMNJENSJ.js} +942 -894
- package/dist/chunk-CMNJENSJ.js.map +1 -0
- package/dist/{chunk-OKBJEIOP.mjs → chunk-JSHLBRFV.mjs} +35 -6
- package/dist/chunk-JSHLBRFV.mjs.map +1 -0
- package/dist/{chunk-TY5YWBLL.mjs → chunk-QSNZ6HPY.mjs} +3 -3
- package/dist/{chunk-HEDHJAKT.mjs → chunk-QWQZUFFA.mjs} +6 -6
- package/dist/{chunk-DFA566KY.js → chunk-SFDOUTEV.js} +4 -4
- package/dist/{chunk-DFA566KY.js.map → chunk-SFDOUTEV.js.map} +1 -1
- package/dist/{chunk-YVKWACDP.mjs → chunk-WU3UXTDS.mjs} +2 -2
- package/dist/{chunk-YVKWACDP.mjs.map → chunk-WU3UXTDS.mjs.map} +1 -1
- package/dist/{chunk-A7Y5YW7Q.js → chunk-YWLSIIE6.js} +47 -18
- package/dist/chunk-YWLSIIE6.js.map +1 -0
- package/dist/client/index.js +5 -5
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.js +5 -5
- package/dist/components/index.mjs +4 -4
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +2 -1
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +3 -3
- package/dist/core/index.mjs +2 -2
- package/dist/email-automations/index.js +118 -118
- package/dist/email-automations/index.mjs +4 -4
- package/dist/features/analytics/index.d.mts +60 -3
- package/dist/features/analytics/index.d.ts +60 -3
- package/dist/features/analytics/index.js +320 -128
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +314 -122
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +5 -5
- package/dist/features/analytics/server.mjs +3 -3
- package/dist/features/blog/index.js +151 -151
- package/dist/features/blog/index.mjs +4 -4
- package/dist/features/blog/server.js +17 -17
- package/dist/features/blog/server.mjs +2 -2
- package/dist/features/help/index.js +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -4
- package/dist/index.mjs +3 -3
- package/dist/offline/index.d.mts +7 -3
- package/dist/offline/index.d.ts +7 -3
- package/dist/offline/index.js +77 -32
- package/dist/offline/index.js.map +1 -1
- package/dist/offline/index.mjs +65 -20
- package/dist/offline/index.mjs.map +1 -1
- package/dist/server/index.d.mts +4 -3
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +4 -4
- package/dist/server/index.mjs +3 -3
- package/messages/en.json +8 -0
- package/messages/it.json +8 -0
- package/package.json +1 -1
- package/pwa/sw.js +17 -1
- package/src/core/factories/RehydrationFactory.ts +47 -5
- package/src/core/factories/__tests__/RehydrationFactory.spec.ts +78 -0
- package/src/core/registry/ModuleRegistry.ts +1 -0
- package/src/features/analytics/analytics.modules.ts +6 -0
- package/src/features/analytics/components/AnalyticsAdminContainer.tsx +20 -0
- package/src/features/analytics/components/AnalyticsDailyUniquesChart.tsx +224 -0
- package/src/features/analytics/components/__tests__/AnalyticsDailyUniquesChart.spec.tsx +88 -0
- package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +22 -0
- package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +15 -2
- package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +38 -0
- package/src/features/analytics/data/AnalyticsAdminService.ts +20 -0
- package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +15 -0
- package/src/features/analytics/data/__tests__/models.spec.ts +29 -0
- package/src/features/analytics/data/analytics-daily-unique-timeline.interface.ts +11 -0
- package/src/features/analytics/data/analytics-daily-unique-timeline.ts +53 -0
- package/src/features/analytics/data/analytics.types.ts +10 -0
- package/src/features/analytics/data/index.ts +2 -0
- package/src/features/analytics/i18n-keys.ts +8 -0
- package/src/features/analytics/index.ts +1 -0
- package/src/offline/server/build-offline-manifest.ts +4 -3
- package/src/offline/sync/__tests__/sync-root.spec.ts +88 -8
- package/src/offline/sync/sync-root.ts +79 -23
- package/src/pwa/__tests__/sw.spec.ts +33 -3
- package/dist/chunk-A7Y5YW7Q.js.map +0 -1
- package/dist/chunk-KMPBSOFB.mjs.map +0 -1
- package/dist/chunk-OKBJEIOP.mjs.map +0 -1
- package/dist/chunk-OO2DBP6L.js.map +0 -1
- /package/dist/{BlockNoteEditor-5HSL74IX.mjs.map → BlockNoteEditor-S7EVHYWT.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-X4UEVI2A.mjs.map → BlockNoteViewer-G3L46SGQ.mjs.map} +0 -0
- /package/dist/{chunk-TY5YWBLL.mjs.map → chunk-QSNZ6HPY.mjs.map} +0 -0
- /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
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
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,
|