@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
|
@@ -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;
|
|
@@ -5,6 +5,8 @@ export { AnalyticsSummary } from "./analytics-summary";
|
|
|
5
5
|
export type { AnalyticsSummaryInterface } from "./analytics-summary.interface";
|
|
6
6
|
export { AnalyticsTimeline } from "./analytics-timeline";
|
|
7
7
|
export type { AnalyticsTimelineInterface } from "./analytics-timeline.interface";
|
|
8
|
+
export { AnalyticsDailyUniqueTimeline } from "./analytics-daily-unique-timeline";
|
|
9
|
+
export type { AnalyticsDailyUniqueTimelineInterface } from "./analytics-daily-unique-timeline.interface";
|
|
8
10
|
export { AnalyticsBreakdown } from "./analytics-breakdown";
|
|
9
11
|
export type { AnalyticsBreakdownInterface } from "./analytics-breakdown.interface";
|
|
10
12
|
export { AnalyticsSession } from "./analytics-session";
|
|
@@ -38,6 +38,8 @@ export const ANALYTICS_ADMIN_I18N_KEYS = [
|
|
|
38
38
|
"analytics.admin.panels.sessions",
|
|
39
39
|
"analytics.admin.panels.journey",
|
|
40
40
|
"analytics.admin.panels.other_sessions",
|
|
41
|
+
"analytics.admin.panels.daily_visitors",
|
|
42
|
+
"analytics.admin.panels.daily_users",
|
|
41
43
|
|
|
42
44
|
// Breakdown tabs
|
|
43
45
|
"analytics.admin.dimensions.source",
|
|
@@ -73,4 +75,10 @@ export const ANALYTICS_ADMIN_I18N_KEYS = [
|
|
|
73
75
|
// Timeline chart series
|
|
74
76
|
"analytics.admin.series.visitors",
|
|
75
77
|
"analytics.admin.series.page_views",
|
|
78
|
+
|
|
79
|
+
// Daily uniques charts
|
|
80
|
+
"analytics.admin.daily_uniques.visitors",
|
|
81
|
+
"analytics.admin.daily_uniques.users",
|
|
82
|
+
"analytics.admin.daily_uniques.today",
|
|
83
|
+
"analytics.admin.daily_uniques.no_data",
|
|
76
84
|
] as const;
|
|
@@ -18,6 +18,7 @@ export { AnalyticsAdminContainer } from "./components/AnalyticsAdminContainer";
|
|
|
18
18
|
export { AnalyticsAdminFilterBar } from "./components/AnalyticsAdminFilterBar";
|
|
19
19
|
export { AnalyticsAdminTiles } from "./components/AnalyticsAdminTiles";
|
|
20
20
|
export { AnalyticsTimelineChart } from "./components/AnalyticsTimelineChart";
|
|
21
|
+
export { AnalyticsDailyUniquesChart } from "./components/AnalyticsDailyUniquesChart";
|
|
21
22
|
export { AnalyticsBreakdownTable } from "./components/AnalyticsBreakdownTable";
|
|
22
23
|
export { AnalyticsSessionsTable } from "./components/AnalyticsSessionsTable";
|
|
23
24
|
export { AnalyticsJourneyContainer } from "./components/AnalyticsJourneyContainer";
|
|
@@ -19,9 +19,10 @@ async function listFiles(dir: string): Promise<string[]> {
|
|
|
19
19
|
* Describes the running build for the service worker's offline shell precache
|
|
20
20
|
* (`/offline-manifest.json`): the build id from `<distDir>/BUILD_ID` and every
|
|
21
21
|
* file under `<distDir>/static`, as the `/_next/static/...` URL Next serves it at.
|
|
22
|
-
* `next dev` writes no BUILD_ID (its folder is `.next/dev`): the id is then
|
|
23
|
-
* asset list
|
|
24
|
-
*
|
|
22
|
+
* `next dev` writes no BUILD_ID (its folder is `.next/dev`): the id is then `dev-` plus a hash
|
|
23
|
+
* of the asset list. Dev chunk names do NOT carry content hashes (an edit rewrites a chunk under
|
|
24
|
+
* the same name), so the id changes only when files appear or disappear; the service worker
|
|
25
|
+
* therefore serves `/_next/static/` network first for a `dev-` build (pwa/sw.js).
|
|
25
26
|
* `distDir` must be the value the app's next.config uses. `extraAssets` are further same-origin
|
|
26
27
|
* URLs the shell needs with no network (the SQLite files the device worker loads).
|
|
27
28
|
*/
|
|
@@ -23,7 +23,7 @@ vi.mock("../../../unified/JsonApiRequest", () => ({
|
|
|
23
23
|
buildUrl: (endpoint: string) => `http://api.test/${endpoint}`,
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
-
import { syncAll, syncRoot } from "../sync-root";
|
|
26
|
+
import { SYNC_FETCH_CONCURRENCY, syncAll, syncRoot } from "../sync-root";
|
|
27
27
|
import { getOfflineState, subscribeOfflineState } from "../../state";
|
|
28
28
|
|
|
29
29
|
type Answer = { ok: boolean; response: number; raw?: any; data?: any };
|
|
@@ -169,9 +169,9 @@ describe("syncRoot", () => {
|
|
|
169
169
|
await syncRoot("C");
|
|
170
170
|
|
|
171
171
|
const endpoints = JsonApiGet.mock.calls.map((call) => call[0].endpoint);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
// The sync answer, then the root record; the other reads run concurrently, in any order.
|
|
173
|
+
expect(endpoints.slice(0, 2)).toEqual(["roots/C/sync", "roots/C"]);
|
|
174
|
+
expect([...endpoints.slice(2)].sort()).toEqual([
|
|
175
175
|
"roots/C/npcs?fetchAll=true&updatedSince=2026-10-31T00:00:00.000Z",
|
|
176
176
|
"templates/t1",
|
|
177
177
|
]);
|
|
@@ -612,12 +612,13 @@ describe("syncRoot", () => {
|
|
|
612
612
|
await syncRoot("C");
|
|
613
613
|
|
|
614
614
|
const calls = JsonApiGet.mock.calls.map((call) => call[0]);
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
615
|
+
// The target reads run concurrently, in any order.
|
|
616
|
+
expect(calls[0].endpoint).toBe("roots/C/sync");
|
|
617
|
+
expect(calls.slice(1).map((call) => call.endpoint).sort()).toEqual([
|
|
618
618
|
"roots/C/clues/k1/player-notes",
|
|
619
|
+
"roots/C/sessions/s1/player-notes",
|
|
619
620
|
]);
|
|
620
|
-
expect(calls
|
|
621
|
+
expect(calls.slice(1).every((call) => call.classKey === PlayerNotes)).toBe(true);
|
|
621
622
|
expect((await store.getRecord("player-notes", "p1"))?.updatedAt).toEqual(new Date("2026-10-31T10:00:00.000Z"));
|
|
622
623
|
expect(await store.getRecord("player-notes", "p3")).toBeDefined();
|
|
623
624
|
expect(await store.getRecord("player-notes", "p2")).toBeUndefined();
|
|
@@ -626,6 +627,85 @@ describe("syncRoot", () => {
|
|
|
626
627
|
warn.mockRestore();
|
|
627
628
|
});
|
|
628
629
|
|
|
630
|
+
it(`keeps at most SYNC_FETCH_CONCURRENCY (${SYNC_FETCH_CONCURRENCY}) record reads in flight at once`, async () => {
|
|
631
|
+
const ids = ["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"];
|
|
632
|
+
const pending: (() => void)[] = [];
|
|
633
|
+
let inFlight = 0;
|
|
634
|
+
let maxInFlight = 0;
|
|
635
|
+
JsonApiGet.mockImplementation(async ({ endpoint }: { endpoint: string }) => {
|
|
636
|
+
if (endpoint === "roots/C/sync") return syncAnswer({ entries: ids.map((id) => entry("templates", id)) });
|
|
637
|
+
inFlight += 1;
|
|
638
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
639
|
+
await new Promise<void>((resolve) => pending.push(resolve));
|
|
640
|
+
inFlight -= 1;
|
|
641
|
+
const id = endpoint.split("/")[1];
|
|
642
|
+
return oneAnswer({ type: "templates", id, attributes: { name: id } });
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const running = syncRoot("C");
|
|
646
|
+
await vi.waitFor(() => expect(pending).toHaveLength(SYNC_FETCH_CONCURRENCY));
|
|
647
|
+
for (let released = 0; released < ids.length; released += 1) {
|
|
648
|
+
await vi.waitFor(() => expect(pending.length).toBeGreaterThan(0));
|
|
649
|
+
pending.shift()!();
|
|
650
|
+
}
|
|
651
|
+
await running;
|
|
652
|
+
|
|
653
|
+
expect(maxInFlight).toBe(SYNC_FETCH_CONCURRENCY);
|
|
654
|
+
expect(JsonApiGet).toHaveBeenCalledTimes(1 + ids.length);
|
|
655
|
+
for (const id of ids) expect((await store.getRecord("templates", id))?.resource.attributes.name).toBe(id);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("finishes the root record's read before any other type's read starts", async () => {
|
|
659
|
+
const events: string[] = [];
|
|
660
|
+
JsonApiGet.mockImplementation(async ({ endpoint }: { endpoint: string }) => {
|
|
661
|
+
if (endpoint === "roots/C/sync") {
|
|
662
|
+
return syncAnswer({ entries: [entry("roots", "C"), entry("npcs", "n1"), entry("templates", "t1")] });
|
|
663
|
+
}
|
|
664
|
+
events.push(`start ${endpoint}`);
|
|
665
|
+
if (endpoint === "roots/C") {
|
|
666
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
667
|
+
events.push(`end ${endpoint}`);
|
|
668
|
+
return oneAnswer({ type: "roots", id: "C", attributes: { name: "Camp" } });
|
|
669
|
+
}
|
|
670
|
+
events.push(`end ${endpoint}`);
|
|
671
|
+
if (endpoint === "roots/C/npcs?fetchAll=true") return listAnswer([npc("n1", "Alda")]);
|
|
672
|
+
if (endpoint === "templates/t1") return oneAnswer({ type: "templates", id: "t1", attributes: { name: "T" } });
|
|
673
|
+
throw new Error(`unexpected GET ${endpoint}`);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
await syncRoot("C");
|
|
677
|
+
|
|
678
|
+
expect(events.slice(0, 2)).toEqual(["start roots/C", "end roots/C"]);
|
|
679
|
+
expect(events).toHaveLength(6);
|
|
680
|
+
expect(await store.getRecord("npcs", "n1")).toBeDefined();
|
|
681
|
+
expect(await store.getRecord("templates", "t1")).toBeDefined();
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("a failing read (not 403/404) stops the sync: no new read starts and nothing is written", async () => {
|
|
685
|
+
const ids = ["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"];
|
|
686
|
+
const pending: (() => void)[] = [];
|
|
687
|
+
JsonApiGet.mockImplementation(async ({ endpoint }: { endpoint: string }) => {
|
|
688
|
+
if (endpoint === "roots/C/sync") {
|
|
689
|
+
return syncAnswer({ entries: [entry("npcs", "n1"), ...ids.map((id) => entry("templates", id))] });
|
|
690
|
+
}
|
|
691
|
+
if (endpoint === "templates/t1") return statusAnswer(500);
|
|
692
|
+
await new Promise<void>((resolve) => pending.push(resolve));
|
|
693
|
+
if (endpoint === "roots/C/npcs?fetchAll=true") return listAnswer([npc("n1", "Alda")]);
|
|
694
|
+
const id = endpoint.split("/")[1];
|
|
695
|
+
return oneAnswer({ type: "templates", id, attributes: { name: id } });
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
await expect(syncRoot("C")).rejects.toThrow("templates/t1 answered 500");
|
|
699
|
+
|
|
700
|
+
// The reads already in flight finish; their results are discarded and nothing more is asked.
|
|
701
|
+
for (const release of pending.splice(0)) release();
|
|
702
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
703
|
+
expect(JsonApiGet).toHaveBeenCalledTimes(1 + SYNC_FETCH_CONCURRENCY);
|
|
704
|
+
expect((await store.getSyncState("C")).serverTime).toBeNull();
|
|
705
|
+
expect(await store.getRecord("npcs", "n1")).toBeUndefined();
|
|
706
|
+
for (const id of ids) expect(await store.getRecord("templates", id)).toBeUndefined();
|
|
707
|
+
});
|
|
708
|
+
|
|
629
709
|
it("a locked answer stores locked and downloads nothing", async () => {
|
|
630
710
|
serve({
|
|
631
711
|
"roots/C/sync": syncAnswer({ locked: true, lockedFrom: "2026-10-30T00:00:00.000Z", entries: [], links: [] }),
|
|
@@ -46,6 +46,49 @@ async function fetchResources(
|
|
|
46
46
|
return Array.isArray(data) ? data : [data];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** How many record reads one root record's sync keeps in flight at once (after the root record). */
|
|
50
|
+
export const SYNC_FETCH_CONCURRENCY = 4;
|
|
51
|
+
|
|
52
|
+
type FetchTask = () => Promise<void>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Runs the tasks with at most `limit` in flight, starting them in list order. After the first
|
|
56
|
+
* rejection no new task starts and the returned promise rejects with that error; tasks already in
|
|
57
|
+
* flight may still finish, their outcome is ignored.
|
|
58
|
+
*/
|
|
59
|
+
async function runWithConcurrency(tasks: FetchTask[], limit: number): Promise<void> {
|
|
60
|
+
if (tasks.length === 0) return;
|
|
61
|
+
await new Promise<void>((resolve, reject) => {
|
|
62
|
+
let next = 0;
|
|
63
|
+
let active = 0;
|
|
64
|
+
let failed = false;
|
|
65
|
+
const launch = (): void => {
|
|
66
|
+
if (failed) return;
|
|
67
|
+
if (next >= tasks.length && active === 0) {
|
|
68
|
+
resolve();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
while (active < limit && next < tasks.length) {
|
|
72
|
+
const task = tasks[next++];
|
|
73
|
+
active += 1;
|
|
74
|
+
new Promise<void>((done) => done(task())).then(
|
|
75
|
+
() => {
|
|
76
|
+
active -= 1;
|
|
77
|
+
launch();
|
|
78
|
+
},
|
|
79
|
+
(error) => {
|
|
80
|
+
active -= 1;
|
|
81
|
+
if (failed) return;
|
|
82
|
+
failed = true;
|
|
83
|
+
reject(error);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
launch();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
49
92
|
async function imagesOfRoot(store: DeviceStore, rootId: string): Promise<{ type: string; id: string; url: string }[]> {
|
|
50
93
|
const images: { type: string; id: string; url: string }[] = [];
|
|
51
94
|
for (const typeConfig of getOfflineConfig().types) {
|
|
@@ -73,8 +116,10 @@ async function removeRootIfIdle(store: DeviceStore, rootId: string): Promise<voi
|
|
|
73
116
|
/**
|
|
74
117
|
* Brings one root record's device copy up to date with the sync answer (C2): records changed since
|
|
75
118
|
* the stored server time are fetched (C3), records gone from the root record are deleted, the links
|
|
76
|
-
* are replaced, and artwork images are downloaded. The
|
|
77
|
-
*
|
|
119
|
+
* are replaced, and artwork images are downloaded. The root record is fetched first; the other
|
|
120
|
+
* reads then run up to SYNC_FETCH_CONCURRENCY at a time. The sync state is stored only once every
|
|
121
|
+
* fetch succeeded (or answered 403/404); the first failure stops the sync before anything is
|
|
122
|
+
* written. A record with a waiting change is never overwritten.
|
|
78
123
|
*/
|
|
79
124
|
export async function syncRoot(rootId: string, kind?: OfflineRootKind): Promise<void> {
|
|
80
125
|
const config = getOfflineConfig();
|
|
@@ -148,13 +193,10 @@ export async function syncRoot(rootId: string, kind?: OfflineRootKind): Promise<
|
|
|
148
193
|
// Records the answers include whose type the device does not sync itself (the root record's
|
|
149
194
|
// owner): kept for display only, so a local answer can include them as the server does.
|
|
150
195
|
const included: any[] = [];
|
|
151
|
-
// The root record of the kind is fetched
|
|
152
|
-
//
|
|
196
|
+
// The root record of the kind is fetched first and alone: a kind's `typeEndpoints` are built
|
|
197
|
+
// from it. Every other read (each type's list, each by-id record, each linked-list target) then
|
|
198
|
+
// runs as one flat task list, at most SYNC_FETCH_CONCURRENCY in flight.
|
|
153
199
|
const rootTypeName = rootKind.module.name;
|
|
154
|
-
const orderedTypes = [
|
|
155
|
-
...config.types.filter((typeConfig) => typeConfig.module.name === rootTypeName),
|
|
156
|
-
...config.types.filter((typeConfig) => typeConfig.module.name !== rootTypeName),
|
|
157
|
-
];
|
|
158
200
|
const rootResource = async (): Promise<StoredRecord["resource"]> => {
|
|
159
201
|
const fresh = fetched.find((resource) => resource?.type === rootTypeName && resource?.id === rootId);
|
|
160
202
|
if (fresh) return fresh;
|
|
@@ -162,16 +204,21 @@ export async function syncRoot(rootId: string, kind?: OfflineRootKind): Promise<
|
|
|
162
204
|
if (storedRoot) return storedRoot.resource;
|
|
163
205
|
throw new Error(`[offline] ${rootTypeName} ${rootId}: the root record is needed to build the type endpoints`);
|
|
164
206
|
};
|
|
165
|
-
|
|
207
|
+
const fetchTasksOf = (typeConfig: (typeof config.types)[number]): FetchTask[] => {
|
|
166
208
|
const changedOfType = changed.filter((entry) => entry.type === typeConfig.module.name);
|
|
167
|
-
if (changedOfType.length === 0)
|
|
209
|
+
if (changedOfType.length === 0) return [];
|
|
168
210
|
|
|
169
211
|
const typeEndpoint = rootKind.typeEndpoints?.find((candidate) => candidate.module.name === typeConfig.module.name);
|
|
170
212
|
if (typeEndpoint) {
|
|
171
213
|
// One full list read under this kind, never with `updatedSince`.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
214
|
+
return [
|
|
215
|
+
async () => {
|
|
216
|
+
const endpoint = typeEndpoint.endpoint({ id: rootId, resource: await rootResource() });
|
|
217
|
+
fetched.push(...(await fetchResources(typeConfig.module, endpoint, skipped, included)));
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
if (typeConfig.fetch === "root-list") {
|
|
175
222
|
// One list read per type. With `updatedSince` when every changed record is inside the
|
|
176
223
|
// window; without it when a record older than the window is missing or stale on the
|
|
177
224
|
// device, so the whole list comes in the same single request (never one read per record).
|
|
@@ -181,8 +228,14 @@ export async function syncRoot(rootId: string, kind?: OfflineRootKind): Promise<
|
|
|
181
228
|
childEndpoint: typeConfig.module,
|
|
182
229
|
}).addAdditionalParam("fetchAll", "true");
|
|
183
230
|
if (since && changedOfType.every(inWindow)) endpoint.addAdditionalParam("updatedSince", since.toISOString());
|
|
184
|
-
|
|
185
|
-
|
|
231
|
+
const generated = endpoint.generate();
|
|
232
|
+
return [
|
|
233
|
+
async () => {
|
|
234
|
+
fetched.push(...(await fetchResources(typeConfig.module, generated, skipped, included)));
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
if (typeConfig.fetch === "linked-list") {
|
|
186
239
|
// Read only as a list per target: the targets are the other ends of the changed records'
|
|
187
240
|
// sync links of `linkKind`; each target's list is read once, and only the type's entries kept.
|
|
188
241
|
if (!typeConfig.linkedList) throw new Error(`[offline] ${typeConfig.module.name}: linked-list needs linkedList`);
|
|
@@ -194,19 +247,22 @@ export async function syncRoot(rootId: string, kind?: OfflineRootKind): Promise<
|
|
|
194
247
|
continue;
|
|
195
248
|
targets.set(keyOf(link.toType, link.toId), { type: link.toType, id: link.toId });
|
|
196
249
|
}
|
|
197
|
-
|
|
250
|
+
return [...targets.values()].map((target) => async () => {
|
|
198
251
|
const resources = await fetchResources(typeConfig.module, endpoint(rootId, target), skipped, included);
|
|
199
252
|
fetched.push(
|
|
200
253
|
...resources.filter((resource) => resource?.type === typeConfig.module.name && changedIds.has(resource.id)),
|
|
201
254
|
);
|
|
202
|
-
}
|
|
203
|
-
} else {
|
|
204
|
-
for (const entry of changedOfType) {
|
|
205
|
-
const endpoint = new EndpointCreator({ endpoint: typeConfig.module, id: entry.id }).generate();
|
|
206
|
-
fetched.push(...(await fetchResources(typeConfig.module, endpoint, skipped, included)));
|
|
207
|
-
}
|
|
255
|
+
});
|
|
208
256
|
}
|
|
209
|
-
|
|
257
|
+
return changedOfType.map((entry) => async () => {
|
|
258
|
+
const endpoint = new EndpointCreator({ endpoint: typeConfig.module, id: entry.id }).generate();
|
|
259
|
+
fetched.push(...(await fetchResources(typeConfig.module, endpoint, skipped, included)));
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
const rootTypes = config.types.filter((typeConfig) => typeConfig.module.name === rootTypeName);
|
|
263
|
+
const otherTypes = config.types.filter((typeConfig) => typeConfig.module.name !== rootTypeName);
|
|
264
|
+
await runWithConcurrency(rootTypes.flatMap(fetchTasksOf), SYNC_FETCH_CONCURRENCY);
|
|
265
|
+
await runWithConcurrency(otherTypes.flatMap(fetchTasksOf), SYNC_FETCH_CONCURRENCY);
|
|
210
266
|
|
|
211
267
|
const fetchedKeys = new Set(fetched.map((resource) => keyOf(resource?.type, resource?.id)));
|
|
212
268
|
const previousImageUrls = new Map<string, string>();
|
|
@@ -463,14 +463,18 @@ describe("pwa/sw.js", () => {
|
|
|
463
463
|
expect(deleted).toEqual(["test-images", "test-shell-build-1"]);
|
|
464
464
|
});
|
|
465
465
|
|
|
466
|
-
function shellWorker(
|
|
466
|
+
function shellWorker(
|
|
467
|
+
shellEntries: Record<string, Response | undefined>,
|
|
468
|
+
offlinePage?: Response,
|
|
469
|
+
buildId: string = "build-2",
|
|
470
|
+
) {
|
|
467
471
|
const worker = loadWorker(SHELL_CONFIG);
|
|
468
472
|
const entries: Record<string, Response | undefined> = { [SHELL_COMPLETE_URL]: new Response(""), ...shellEntries };
|
|
469
473
|
const shellCache = makeCache({ match: vi.fn(async (url: string) => entries[url]) });
|
|
470
474
|
const offlineCache = makeCache({ match: vi.fn().mockResolvedValue(offlinePage) });
|
|
471
|
-
worker.sandbox.caches.keys.mockResolvedValue(["test-offline-abc12345",
|
|
475
|
+
worker.sandbox.caches.keys.mockResolvedValue(["test-offline-abc12345", `test-shell-${buildId}`]);
|
|
472
476
|
worker.sandbox.caches.open.mockImplementation(async (name: string) =>
|
|
473
|
-
name ===
|
|
477
|
+
name === `test-shell-${buildId}` ? shellCache : offlineCache,
|
|
474
478
|
);
|
|
475
479
|
worker.sandbox.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
|
|
476
480
|
return { ...worker, shellCache, offlineCache };
|
|
@@ -563,6 +567,32 @@ describe("pwa/sw.js", () => {
|
|
|
563
567
|
expect(sandbox.fetch).toHaveBeenCalledWith(missRequest);
|
|
564
568
|
});
|
|
565
569
|
|
|
570
|
+
it("under next dev (a dev- build id) a GET under /_next/static/ goes to the network first, keeps the cache current, and falls back to the cache offline", async () => {
|
|
571
|
+
// next dev keeps a chunk's file name when its content changes: answering from the cache
|
|
572
|
+
// would hand the page a chunk of an earlier build next to the server's current ones.
|
|
573
|
+
const url = `${ORIGIN}/_next/static/chunks/a.js`;
|
|
574
|
+
const cachedChunk = new Response("cached chunk");
|
|
575
|
+
const { sandbox, dispatch, shellCache } = shellWorker({ [url]: cachedChunk }, undefined, "dev-0123456789abcdef");
|
|
576
|
+
const request = { method: "GET", mode: "no-cors", destination: "script", url };
|
|
577
|
+
|
|
578
|
+
const network = new Response("network chunk");
|
|
579
|
+
sandbox.fetch.mockResolvedValue(network);
|
|
580
|
+
const online = dispatch("fetch", { request });
|
|
581
|
+
expect(online.respondWith).toHaveBeenCalledTimes(1);
|
|
582
|
+
expect(await online.respondWithPromise).toBe(network);
|
|
583
|
+
expect(sandbox.fetch).toHaveBeenCalledWith(request);
|
|
584
|
+
expect(shellCache.put).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(shellCache.put.mock.calls[0][0]).toBe(url);
|
|
586
|
+
expect(await (shellCache.put.mock.calls[0][1] as Response).text()).toBe("network chunk");
|
|
587
|
+
|
|
588
|
+
sandbox.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
|
|
589
|
+
const offline = dispatch("fetch", { request });
|
|
590
|
+
expect(await offline.respondWithPromise).toBe(cachedChunk);
|
|
591
|
+
|
|
592
|
+
const missing = dispatch("fetch", { request: { ...request, url: `${ORIGIN}/_next/static/chunks/missing.js` } });
|
|
593
|
+
expect(((await missing.respondWithPromise) as Response).type).toBe("error");
|
|
594
|
+
});
|
|
595
|
+
|
|
566
596
|
it("another same-origin GET in the shell cache (the device worker, SQLite) goes to the network first and is answered from the cache when the network fails", async () => {
|
|
567
597
|
const workerUrl = `${ORIGIN}/offline-sqlite/device-worker.mjs`;
|
|
568
598
|
const cachedWorker = new Response("cached worker");
|