@carlonicora/nextjs-jsonapi 6.18.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.
- package/dist/{BlockNoteEditor-KSOVYIOL.mjs → BlockNoteEditor-7JRJ3XWV.mjs} +5 -5
- package/dist/{BlockNoteEditor-HHTAZKRO.js → BlockNoteEditor-MH2D5L4A.js} +20 -20
- package/dist/{BlockNoteEditor-HHTAZKRO.js.map → BlockNoteEditor-MH2D5L4A.js.map} +1 -1
- package/dist/{BlockNoteViewer-XYKFY77F.js → BlockNoteViewer-72J3CBAH.js} +4 -4
- package/dist/{BlockNoteViewer-XYKFY77F.js.map → BlockNoteViewer-72J3CBAH.js.map} +1 -1
- package/dist/{BlockNoteViewer-X4UEVI2A.mjs → BlockNoteViewer-UKYEU3E3.mjs} +3 -3
- 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-OKBJEIOP.mjs → chunk-2U57XVNF.mjs} +1 -1
- package/dist/chunk-2U57XVNF.mjs.map +1 -0
- package/dist/{chunk-KMPBSOFB.mjs → chunk-ASORDSWQ.mjs} +50 -2
- package/dist/chunk-ASORDSWQ.mjs.map +1 -0
- package/dist/{chunk-B6WMZXRY.js → chunk-IN2F6HI2.js} +1242 -1206
- package/dist/chunk-IN2F6HI2.js.map +1 -0
- package/dist/{chunk-DFA566KY.js → chunk-JAOBCIE7.js} +4 -4
- package/dist/{chunk-DFA566KY.js.map → chunk-JAOBCIE7.js.map} +1 -1
- package/dist/{chunk-YVKWACDP.mjs → chunk-LSZZIQJ5.mjs} +2 -2
- package/dist/{chunk-I5BQLY7V.js → chunk-MVAIYPIN.js} +9 -9
- package/dist/{chunk-I5BQLY7V.js.map → chunk-MVAIYPIN.js.map} +1 -1
- package/dist/{chunk-MBLF236G.mjs → chunk-NKXNOGMH.mjs} +47 -11
- package/dist/chunk-NKXNOGMH.mjs.map +1 -0
- package/dist/{chunk-OO2DBP6L.js → chunk-SGLFQ55U.js} +942 -894
- package/dist/chunk-SGLFQ55U.js.map +1 -0
- package/dist/{chunk-TY5YWBLL.mjs → chunk-SJ3K3ATF.mjs} +3 -3
- package/dist/{chunk-A7Y5YW7Q.js → chunk-V7CHD7QT.js} +1 -1
- package/dist/{chunk-A7Y5YW7Q.js.map → chunk-V7CHD7QT.js.map} +1 -1
- package/dist/client/index.js +5 -5
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.d.mts +7 -1
- package/dist/components/index.d.ts +7 -1
- 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.js +14 -14
- package/dist/offline/index.mjs +1 -1
- 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/src/components/forms/FormDateTime.tsx +55 -3
- 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/dist/chunk-B6WMZXRY.js.map +0 -1
- package/dist/chunk-KMPBSOFB.mjs.map +0 -1
- package/dist/chunk-MBLF236G.mjs.map +0 -1
- package/dist/chunk-OKBJEIOP.mjs.map +0 -1
- package/dist/chunk-OO2DBP6L.js.map +0 -1
- /package/dist/{BlockNoteEditor-KSOVYIOL.mjs.map → BlockNoteEditor-7JRJ3XWV.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-X4UEVI2A.mjs.map → BlockNoteViewer-UKYEU3E3.mjs.map} +0 -0
- /package/dist/{chunk-YVKWACDP.mjs.map → chunk-LSZZIQJ5.mjs.map} +0 -0
- /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;
|