web: adopt Cache Components, Partial Prefetching and ensureStatic
web / ci (push) Successful in 2m2s
web / ci (push) Successful in 2m2s
This commit is contained in:
70 files changed
+1285
-316
No files matched your search
@@ -22,6 +22,13 @@ function remotePatterns() {
|
|||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
cacheComponents: true,
|
||||||
|
partialPrefetching: true,
|
||||||
|
// Next renders every page per request, uncached, for the crawlers and
|
||||||
|
// link-preview bots on its "HTML-limited" list, so their metadata is never
|
||||||
|
// streamed after <head>. No page here streams metadata (it all comes from
|
||||||
|
// cached CMS data), so no bot needs that; this pattern matches none
|
||||||
|
htmlLimitedBots: /(?!)/,
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: remotePatterns(),
|
remotePatterns: remotePatterns(),
|
||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { Metadata, ResolvingMetadata } from "next";
|
import { Metadata, ResolvingMetadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,13 @@ import {
|
|||||||
} from "@/components/general/GenericPageView";
|
} from "@/components/general/GenericPageView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
|
// Navigations wait for the page instead of showing a loading shell first:
|
||||||
|
// the response has to be able to turn into a real 404 for an unknown path
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
function getWagtailUrlPath(url: string[]): string {
|
function getWagtailUrlPath(url: string[]): string {
|
||||||
// for the page /foo/bar we need to look for `/home/foo/bar/`
|
// for the page /foo/bar we need to look for `/home/foo/bar/`
|
||||||
return `/home/${url.join("/")}/`;
|
return `/home/${url.join("/")}/`;
|
||||||
@@ -22,10 +29,7 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
const { data, error } = await getClient().query(allGenericSlugsQuery, {});
|
const data = await cmsQuery(allGenericSlugsQuery, {});
|
||||||
if (error) {
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
if (!data?.pages) {
|
if (!data?.pages) {
|
||||||
throw new Error("Failed to generate static params for generic subpages");
|
throw new Error("Failed to generate static params for generic subpages");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ import {
|
|||||||
loadNewsPageProps,
|
loadNewsPageProps,
|
||||||
} from "@/components/news/NewsPageView";
|
} from "@/components/news/NewsPageView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
import { latestSlugParams } from "@/lib/staticParams";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
|
// Navigations wait for the page instead of showing a loading shell first:
|
||||||
|
// the response has to be able to turn into a real 404 for an unknown path
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
// Prerender nothing at build time; render and cache on first request
|
return latestSlugParams("news.NewsPage");
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Params = Promise<{ slug: string }>;
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Metadata, ResolvingMetadata } from "next";
|
import { Metadata, ResolvingMetadata } from "next";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import {
|
import {
|
||||||
NewsIndexView,
|
NewsIndexView,
|
||||||
loadNewsIndexProps,
|
loadNewsIndexProps,
|
||||||
@@ -8,12 +8,14 @@ import { NewsIndexFragment } from "@/gql/graphql";
|
|||||||
import { newsIndexMetadataQuery } from "@/lib/news";
|
import { newsIndexMetadataQuery } from "@/lib/news";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
): Promise<Metadata | null> {
|
): Promise<Metadata | null> {
|
||||||
const { data, error } = await getClient().query(newsIndexMetadataQuery, {});
|
const data = await cmsQuery(newsIndexMetadataQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
if (!data?.index) return null;
|
if (!data?.index) return null;
|
||||||
return getSeoMetadata(data.index as NewsIndexFragment, parent);
|
return getSeoMetadata(data.index as NewsIndexFragment, parent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
|
||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { CMS_CACHE_TAG } from "@/lib/revalidation";
|
import { setInternalRevalidateSecret } from "@/lib/internalRevalidate";
|
||||||
|
import { CMS_CACHE_TAG, TODAY_CACHE_TAG } from "@/lib/revalidation";
|
||||||
import { POST } from "./route.ts";
|
import { POST } from "./route.ts";
|
||||||
|
|
||||||
const request = (headers: Record<string, string> = {}) =>
|
const request = (headers: Record<string, string> = {}, query = "") =>
|
||||||
new Request("http://localhost/api/revalidate", {
|
new NextRequest(`http://localhost/api/revalidate${query}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
}) as unknown as NextRequest;
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
setInternalRevalidateSecret("");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/revalidate", () => {
|
describe("POST /api/revalidate", () => {
|
||||||
@@ -47,4 +49,119 @@ describe("POST /api/revalidate", () => {
|
|||||||
expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG });
|
expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG });
|
||||||
expect(revalidateTag).toHaveBeenCalledWith(CMS_CACHE_TAG, { expire: 0 });
|
expect(revalidateTag).toHaveBeenCalledWith(CMS_CACHE_TAG, { expire: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("purges only the today tag when asked to", async () => {
|
||||||
|
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
request({ "x-revalidate-secret": "hemmelig" }, "?tag=today")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(revalidateTag).toHaveBeenCalledTimes(1);
|
||||||
|
expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tags it doesn't know", async () => {
|
||||||
|
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
request({ "x-revalidate-secret": "hemmelig" }, "?tag=noe-annet")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(revalidateTag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("follow-up purge", () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
const followUpCalls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||||
|
|
||||||
|
function setUp() {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||||
|
vi.stubEnv("PORT", "4321");
|
||||||
|
vi.stubEnv("HOSTNAME", "0.0.0.0");
|
||||||
|
setInternalRevalidateSecret("intern");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges again a few seconds after a webhook, as the server itself", async () => {
|
||||||
|
setUp();
|
||||||
|
|
||||||
|
await POST(request({ "x-revalidate-secret": "hemmelig" }));
|
||||||
|
await vi.advanceTimersByTimeAsync(4_000);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1_500);
|
||||||
|
expect(followUpCalls()).toEqual([
|
||||||
|
"http://127.0.0.1:4321/api/revalidate?tag=cms",
|
||||||
|
]);
|
||||||
|
expect(fetchMock.mock.calls[0][1].headers["x-revalidate-secret"]).toBe(
|
||||||
|
"intern"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends one follow-up after a burst of webhooks", async () => {
|
||||||
|
setUp();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await POST(request({ "x-revalidate-secret": "hemmelig" }));
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
}
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
|
||||||
|
expect(followUpCalls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not scheduled by the server's own purges", async () => {
|
||||||
|
setUp();
|
||||||
|
|
||||||
|
await POST(request({ "x-revalidate-secret": "intern" }, "?tag=today"));
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is skipped where the server can't call itself", async () => {
|
||||||
|
setUp();
|
||||||
|
setInternalRevalidateSecret("");
|
||||||
|
|
||||||
|
await POST(request({ "x-revalidate-secret": "hemmelig" }));
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the server's own purges", () => {
|
||||||
|
it("are accepted without a configured webhook secret", async () => {
|
||||||
|
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", undefined);
|
||||||
|
setInternalRevalidateSecret("intern");
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
request({ "x-revalidate-secret": "intern" }, "?tag=today")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("don't open the endpoint to an empty secret", async () => {
|
||||||
|
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||||
|
|
||||||
|
expect((await POST(request())).status).toBe(401);
|
||||||
|
expect((await POST(request({ "x-revalidate-secret": "" }))).status).toBe(
|
||||||
|
401
|
||||||
|
);
|
||||||
|
expect(revalidateTag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,11 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { CMS_CACHE_TAG } from "@/lib/revalidation";
|
import {
|
||||||
|
internalRevalidateSecret,
|
||||||
|
purgeOwnCache,
|
||||||
|
} from "@/lib/internalRevalidate";
|
||||||
|
import { CACHE_TAGS, CMS_CACHE_TAG, type CacheTag } from "@/lib/revalidation";
|
||||||
|
|
||||||
function secretMatches(provided: string, expected: string): boolean {
|
function secretMatches(provided: string, expected: string): boolean {
|
||||||
const a = Buffer.from(provided);
|
const a = Buffer.from(provided);
|
||||||
@@ -10,19 +14,55 @@ function secretMatches(provided: string, expected: string): boolean {
|
|||||||
return a.length === b.length && timingSafeEqual(a, b);
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOLLOW_UP_DELAY_MS = 5_000;
|
||||||
|
const followUps = new Map<CacheTag, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Next stamps a cached page when its render finishes. A page that was being
|
||||||
|
// rendered while a purge came in (two saves in quick succession) therefore
|
||||||
|
// looks newer than the purge and would keep the older content. A second
|
||||||
|
// purge shortly after the last one in a burst catches those pages
|
||||||
|
function scheduleFollowUpPurge(tag: CacheTag) {
|
||||||
|
clearTimeout(followUps.get(tag));
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
followUps.delete(tag);
|
||||||
|
const error = await purgeOwnCache(tag);
|
||||||
|
if (error) console.error(`Follow-up purge of "${tag}" failed: ${error}`);
|
||||||
|
}, FOLLOW_UP_DELAY_MS);
|
||||||
|
timer.unref();
|
||||||
|
followUps.set(tag, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/revalidate purges the cms tag (the CMS webhook);
|
||||||
|
// POST /api/revalidate?tag=today purges only what depends on the day
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "revalidation is not configured" },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const provided = req.headers.get("x-revalidate-secret") ?? "";
|
const provided = req.headers.get("x-revalidate-secret") ?? "";
|
||||||
if (!secretMatches(provided, secret)) {
|
// the server's own startup and midnight purges (instrumentation.ts)
|
||||||
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
|
const internal = internalRevalidateSecret();
|
||||||
|
const isInternal = internal !== undefined && secretMatches(provided, internal);
|
||||||
|
|
||||||
|
if (!isInternal) {
|
||||||
|
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "revalidation is not configured" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!secretMatches(provided, secret)) {
|
||||||
|
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = req.nextUrl.searchParams.get("tag") ?? CMS_CACHE_TAG;
|
||||||
|
if (!CACHE_TAGS.includes(tag as CacheTag)) {
|
||||||
|
return NextResponse.json({ error: "unknown tag" }, { status: 400 });
|
||||||
}
|
}
|
||||||
// expire: 0 purges immediately; the next request renders fresh
|
// expire: 0 purges immediately; the next request renders fresh
|
||||||
revalidateTag(CMS_CACHE_TAG, { expire: 0 });
|
revalidateTag(tag, { expire: 0 });
|
||||||
return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG });
|
// not after the server's own purges, which would loop; and only where the
|
||||||
|
// server can call itself (not in dev)
|
||||||
|
if (!isInternal && internal !== undefined) {
|
||||||
|
scheduleFollowUpPurge(tag as CacheTag);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ revalidated: true, tag });
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,19 @@ import { NextRequest } from "next/server";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const queryMock = vi.fn();
|
const queryMock = vi.fn();
|
||||||
|
// mirrors cmsQueryForToday: returns the data, throws on a GraphQL error
|
||||||
vi.mock("@/app/client", () => ({
|
vi.mock("@/app/client", () => ({
|
||||||
getClient: () => ({ query: queryMock }),
|
cmsQueryForToday: async (...args: unknown[]) => {
|
||||||
|
const { data, error } = await queryMock(...args);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
// the real one is a server-only cached function; read the (fake) clock instead
|
||||||
|
vi.mock("@/lib/today", async () => {
|
||||||
|
const { osloDay, startOfOsloDay } = await import("@/lib/date");
|
||||||
|
return { getToday: async () => startOfOsloDay(osloDay()) };
|
||||||
|
});
|
||||||
|
|
||||||
import { GET } from "./route.ts";
|
import { GET } from "./route.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQueryForToday } from "@/app/client";
|
||||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
import { getToday } from "@/lib/today";
|
||||||
import {
|
import {
|
||||||
eventsOverviewQuery,
|
eventsOverviewQuery,
|
||||||
getSingularEvents,
|
getSingularEvents,
|
||||||
@@ -46,14 +46,7 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await getClient().query(
|
const data = await cmsQueryForToday(eventsOverviewQuery, {});
|
||||||
eventsOverviewQuery,
|
|
||||||
{},
|
|
||||||
cachedUntilOsloMidnight()
|
|
||||||
);
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
!data?.index ||
|
!data?.index ||
|
||||||
!data?.events?.futureEvents ||
|
!data?.events?.futureEvents ||
|
||||||
@@ -64,6 +57,7 @@ export async function GET(req: NextRequest) {
|
|||||||
throw new Error("Failed to fetch events");
|
throw new Error("Failed to fetch events");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const today = await getToday();
|
||||||
const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[];
|
const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[];
|
||||||
const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
|
const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
|
||||||
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
|
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
|
||||||
@@ -71,7 +65,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"];
|
const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"];
|
||||||
const compactEvents = futureEvents.map((event) => {
|
const compactEvents = futureEvents.map((event) => {
|
||||||
const futureOccurrences = getFutureOccurrences(event);
|
const futureOccurrences = getFutureOccurrences(event, today);
|
||||||
const nextOccurrence = futureOccurrences.length
|
const nextOccurrence = futureOccurrences.length
|
||||||
? futureOccurrences[0]
|
? futureOccurrences[0]
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ import {
|
|||||||
loadEventPageProps,
|
loadEventPageProps,
|
||||||
} from "@/components/events/EventPageView";
|
} from "@/components/events/EventPageView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
import { latestSlugParams } from "@/lib/staticParams";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
|
// Navigations wait for the page instead of showing a loading shell first:
|
||||||
|
// the response has to be able to turn into a real 404 for an unknown path
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
// Prerender nothing at build time; render and cache on first request
|
return latestSlugParams("events.EventPage");
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Params = Promise<{ slug: string }>;
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Metadata, ResolvingMetadata } from "next";
|
import { Metadata, ResolvingMetadata } from "next";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import {
|
import {
|
||||||
EventIndexView,
|
EventIndexView,
|
||||||
loadEventIndexProps,
|
loadEventIndexProps,
|
||||||
@@ -8,22 +8,20 @@ import { EventIndexFragment } from "@/gql/graphql";
|
|||||||
import { eventIndexMetadataQuery } from "@/lib/event";
|
import { eventIndexMetadataQuery } from "@/lib/event";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// The event list renders per request (it depends on the URL's filters), so
|
||||||
|
// only the shell around it has to stay prerendered
|
||||||
|
export const ensureStatic = "shell";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
): Promise<Metadata | null> {
|
): Promise<Metadata | null> {
|
||||||
const { data, error } = await getClient().query(eventIndexMetadataQuery, {});
|
const data = await cmsQuery(eventIndexMetadataQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
if (!data?.index) return null;
|
if (!data?.index) return null;
|
||||||
return getSeoMetadata(data.index as EventIndexFragment, parent);
|
return getSeoMetadata(data.index as EventIndexFragment, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({
|
export default async function Page() {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
}) {
|
|
||||||
await searchParams;
|
|
||||||
const props = await loadEventIndexProps();
|
const props = await loadEventIndexProps();
|
||||||
return <EventIndexView {...props} />;
|
return <EventIndexView {...props} />;
|
||||||
}
|
}
|
||||||
+52
-5
@@ -1,9 +1,16 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { cacheExchange, createClient, fetchExchange } from "@urql/core";
|
import {
|
||||||
|
type AnyVariables,
|
||||||
|
type TypedDocumentNode,
|
||||||
|
cacheExchange,
|
||||||
|
createClient,
|
||||||
|
fetchExchange,
|
||||||
|
stringifyDocument,
|
||||||
|
} from "@urql/core";
|
||||||
import { registerUrql } from "@urql/next/rsc";
|
import { registerUrql } from "@urql/next/rsc";
|
||||||
|
|
||||||
import { CMS_CACHE_TAG } from "@/lib/revalidation";
|
import { cmsCache, cmsCacheForToday } from "@/lib/cmsCache";
|
||||||
|
|
||||||
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
|
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
|
||||||
if (!wagtailBaseUrl) {
|
if (!wagtailBaseUrl) {
|
||||||
@@ -15,10 +22,50 @@ const makeClient = () => {
|
|||||||
return createClient({
|
return createClient({
|
||||||
url: graphqlEndpoint,
|
url: graphqlEndpoint,
|
||||||
exchanges: [cacheExchange, fetchExchange],
|
exchanges: [cacheExchange, fetchExchange],
|
||||||
// Cache all queries in the Data Cache until revalidateTag(CMS_CACHE_TAG);
|
|
||||||
// override per operation with the contexts in @/lib/revalidation
|
|
||||||
fetchOptions: { cache: "force-cache", next: { tags: [CMS_CACHE_TAG] } },
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Uncached. Only for per-user (search) and token-scoped (preview) queries,
|
||||||
|
// together with the `uncached` context; everything else goes through cmsQuery
|
||||||
export const { getClient } = registerUrql(makeClient);
|
export const { getClient } = registerUrql(makeClient);
|
||||||
|
|
||||||
|
// Throws on GraphQL and network errors, which keeps them out of the cache
|
||||||
|
async function runQuery(query: string, variables: AnyVariables) {
|
||||||
|
const { data, error } = await getClient().query(query, variables);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cached functions take the query as text: their arguments are the cache
|
||||||
|
// key, and urql adds bookkeeping properties to document objects it has seen
|
||||||
|
async function cachedQuery(query: string, variables: AnyVariables) {
|
||||||
|
"use cache";
|
||||||
|
cmsCache();
|
||||||
|
return runQuery(query, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cachedQueryForToday(query: string, variables: AnyVariables) {
|
||||||
|
"use cache";
|
||||||
|
cmsCacheForToday();
|
||||||
|
return runQuery(query, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every cacheable CMS read goes through one of the two functions below, so
|
||||||
|
// all of them carry the tag the CMS webhook purges
|
||||||
|
|
||||||
|
// Cached until the content is edited in the CMS
|
||||||
|
export function cmsQuery<Data, Variables extends AnyVariables>(
|
||||||
|
document: TypedDocumentNode<Data, Variables>,
|
||||||
|
variables: Variables
|
||||||
|
): Promise<Data | undefined> {
|
||||||
|
return cachedQuery(stringifyDocument(document), variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For queries where the CMS decides by today's date what to return
|
||||||
|
// (futureEvents): also purged at Oslo midnight
|
||||||
|
export function cmsQueryForToday<Data, Variables extends AnyVariables>(
|
||||||
|
document: TypedDocumentNode<Data, Variables>,
|
||||||
|
variables: Variables
|
||||||
|
): Promise<Data | undefined> {
|
||||||
|
return cachedQueryForToday(stringifyDocument(document), variables);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Metadata, ResolvingMetadata } from "next";
|
import { Metadata, ResolvingMetadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import {
|
import {
|
||||||
AssociationPageView,
|
AssociationPageView,
|
||||||
loadAssociationPageProps,
|
loadAssociationPageProps,
|
||||||
@@ -8,6 +8,13 @@ import {
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
|
// Navigations wait for the page instead of showing a loading shell first:
|
||||||
|
// the response has to be able to turn into a real 404 for an unknown path
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
type Params = Promise<{ slug: string }>;
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
@@ -29,11 +36,7 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
const { data, error } = await getClient().query(allAssociationSlugsQuery, {});
|
const data = await cmsQuery(allAssociationSlugsQuery, {});
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
if (!data?.pages) {
|
if (!data?.pages) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Failed to generate static params for subpages of /foreninger"
|
"Failed to generate static params for subpages of /foreninger"
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
} from "@/components/associations/AssociationIndexView";
|
} from "@/components/associations/AssociationIndexView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
} from "@/components/contact/ContactIndexView";
|
} from "@/components/contact/ContactIndexView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
|
|||||||
+10
-5
@@ -1,5 +1,7 @@
|
|||||||
import "@/css/main.scss";
|
import "@/css/main.scss";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { SeasonalHeader } from "@/components/layout/SeasonalHeader";
|
||||||
|
import { TodayProvider } from "@/components/general/Today";
|
||||||
|
import { getOsloDay } from "@/lib/today";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
@@ -26,11 +28,12 @@ export const metadata: Metadata = {
|
|||||||
...baseUrlMetadata,
|
...baseUrlMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const day = await getOsloDay();
|
||||||
return (
|
return (
|
||||||
<html lang="no">
|
<html lang="no">
|
||||||
<head>
|
<head>
|
||||||
@@ -61,9 +64,11 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
<Header />
|
<TodayProvider day={day}>
|
||||||
{children}
|
<SeasonalHeader />
|
||||||
<Footer />
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</TodayProvider>
|
||||||
</NuqsAdapter>
|
</NuqsAdapter>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Metadata, ResolvingMetadata } from "next";
|
import { Metadata, ResolvingMetadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import {
|
import {
|
||||||
VenuePageView,
|
VenuePageView,
|
||||||
loadVenuePageProps,
|
loadVenuePageProps,
|
||||||
@@ -8,6 +8,13 @@ import {
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
|
// Navigations wait for the page instead of showing a loading shell first:
|
||||||
|
// the response has to be able to turn into a real 404 for an unknown path
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
type Params = Promise<{ slug: string }>;
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
const EXCLUDED_SLUGS = ["hele-huset"];
|
const EXCLUDED_SLUGS = ["hele-huset"];
|
||||||
@@ -31,11 +38,7 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
const { data, error } = await getClient().query(allVenueSlugsQuery, {});
|
const data = await cmsQuery(allVenueSlugsQuery, {});
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
if (!data?.pages) {
|
if (!data?.pages) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Failed to generate static params for subpages of /lokaler"
|
"Failed to generate static params for subpages of /lokaler"
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
} from "@/components/venues/VenueIndexView";
|
} from "@/components/venues/VenueIndexView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import {
|
|||||||
loadHomePageProps,
|
loadHomePageProps,
|
||||||
} from "@/components/home/HomePageView";
|
} from "@/components/home/HomePageView";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const props = await loadHomePageProps();
|
const props = await loadHomePageProps();
|
||||||
return <HomePageView {...props} />;
|
return <HomePageView {...props} />;
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ import {
|
|||||||
VenueRentalIndexFragment,
|
VenueRentalIndexFragment,
|
||||||
} from "@/gql/graphql";
|
} from "@/gql/graphql";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const revalidate = 0;
|
// Rendered per request from the editor's preview cookie; there is nothing
|
||||||
|
// to prerender, so let the route block instead of requiring a loading shell
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
const previewPageQuery = graphql(`
|
const previewPageQuery = graphql(`
|
||||||
query previewPage($token: String!) {
|
query previewPage($token: String!) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { getClient } from "@/app/client";
|
import { getClient } from "@/app/client";
|
||||||
import { uncached } from "@/lib/revalidation";
|
import { uncached } from "@/lib/revalidation";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +10,10 @@ import { graphql } from "@/gql";
|
|||||||
|
|
||||||
// TODO: seo metadata?
|
// TODO: seo metadata?
|
||||||
|
|
||||||
|
// Results depend on the query in the URL, so the route renders per request
|
||||||
|
// and is allowed to block
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
export default async function Page({
|
export default async function Page({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -17,6 +22,8 @@ export default async function Page({
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { q: query } = (await searchParams) ?? {};
|
const { q: query } = (await searchParams) ?? {};
|
||||||
|
// search always runs at request time
|
||||||
|
await connection();
|
||||||
let results: SearchResult[] = [];
|
let results: SearchResult[] = [];
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
const RESULT_LIMIT = 500;
|
const RESULT_LIMIT = 500;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
} from "@/components/sponsor/SponsorsPageView";
|
} from "@/components/sponsor/SponsorsPageView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
} from "@/components/studio/StudioPageView";
|
} from "@/components/studio/StudioPageView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
} from "@/components/venues/VenueRentalIndexView";
|
} from "@/components/venues/VenueRentalIndexView";
|
||||||
import { getSeoMetadata } from "@/lib/seo";
|
import { getSeoMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
// Fail the build if this route stops being fully prerendered
|
||||||
|
export const ensureStatic = "navigation";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { AssociationFragment, AssociationIndexFragment } from "@/gql/graphql";
|
import { AssociationFragment, AssociationIndexFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { AssociationList } from "@/components/associations/AssociationList";
|
import { AssociationList } from "@/components/associations/AssociationList";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
@@ -67,8 +67,7 @@ export type AssociationIndexViewProps = {
|
|||||||
export async function loadAssociationIndexProps(overrides?: {
|
export async function loadAssociationIndexProps(overrides?: {
|
||||||
indexOverride?: AssociationIndexFragment;
|
indexOverride?: AssociationIndexFragment;
|
||||||
}): Promise<AssociationIndexViewProps> {
|
}): Promise<AssociationIndexViewProps> {
|
||||||
const { data, error } = await getClient().query(allAssociationsQuery, {});
|
const data = await cmsQuery(allAssociationsQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const index =
|
const index =
|
||||||
overrides?.indexOverride ?? (data?.index as AssociationIndexFragment | undefined);
|
overrides?.indexOverride ?? (data?.index as AssociationIndexFragment | undefined);
|
||||||
if (!index) throw new Error("Failed to load /foreninger");
|
if (!index) throw new Error("Failed to load /foreninger");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { AssociationFragment } from "@/gql/graphql";
|
import { AssociationFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { AssociationHeader } from "@/components/associations/AssociationHeader";
|
import { AssociationHeader } from "@/components/associations/AssociationHeader";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
|
|
||||||
@@ -27,10 +27,9 @@ export async function loadAssociationPageProps(args: {
|
|||||||
return { association: args.associationOverride };
|
return { association: args.associationOverride };
|
||||||
}
|
}
|
||||||
if (!args.slug) throw new Error("loadAssociationPageProps needs slug or associationOverride");
|
if (!args.slug) throw new Error("loadAssociationPageProps needs slug or associationOverride");
|
||||||
const { data, error } = await getClient().query(associationBySlugQuery, {
|
const data = await cmsQuery(associationBySlugQuery, {
|
||||||
slug: args.slug,
|
slug: args.slug,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const association = data?.association as AssociationFragment | undefined;
|
const association = data?.association as AssociationFragment | undefined;
|
||||||
if (!association) return null;
|
if (!association) return null;
|
||||||
return { association };
|
return { association };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getPrettyOpeningHoursForFunction,
|
getPrettyOpeningHoursForFunction,
|
||||||
PrettyOpeningHours,
|
PrettyOpeningHours,
|
||||||
} from "@/lib/openinghours";
|
} from "@/lib/openinghours";
|
||||||
|
import { getToday } from "@/lib/today";
|
||||||
import styles from "./openingHoursSectionBlock.module.scss";
|
import styles from "./openingHoursSectionBlock.module.scss";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ function OpeningHoursSubsection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function OpeningHoursSectionBlock() {
|
export async function OpeningHoursSectionBlock() {
|
||||||
const allOpeningHours = await getOpeningHours();
|
const allOpeningHours = await getOpeningHours(await getToday());
|
||||||
const subsections = [
|
const subsections = [
|
||||||
["glassbaren", "Glassbaren"],
|
["glassbaren", "Glassbaren"],
|
||||||
["bokcafeen", "Bokcaféen"],
|
["bokcafeen", "Bokcaféen"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { ContactIndexFragment } from "@/gql/graphql";
|
import { ContactIndexFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { GeneralContactBlock } from "@/components/blocks/GeneralContactBlock";
|
import { GeneralContactBlock } from "@/components/blocks/GeneralContactBlock";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
@@ -36,8 +36,7 @@ export async function loadContactIndexProps(overrides?: {
|
|||||||
if (overrides?.indexOverride) {
|
if (overrides?.indexOverride) {
|
||||||
return { index: overrides.indexOverride };
|
return { index: overrides.indexOverride };
|
||||||
}
|
}
|
||||||
const { data, error } = await getClient().query(contactQuery, {});
|
const data = await cmsQuery(contactQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const index = data?.index as ContactIndexFragment | undefined;
|
const index = data?.index as ContactIndexFragment | undefined;
|
||||||
if (!index) throw new Error("Failed to load /kontakt");
|
if (!index) throw new Error("Failed to load /kontakt");
|
||||||
return { index };
|
return { index };
|
||||||
|
|||||||
@@ -8,22 +8,24 @@ import {
|
|||||||
compareDates,
|
compareDates,
|
||||||
} from "@/lib/date";
|
} from "@/lib/date";
|
||||||
import { EventFragment, EventOccurrence } from "@/lib/event";
|
import { EventFragment, EventOccurrence } from "@/lib/event";
|
||||||
|
import { useToday } from "@/components/general/Today";
|
||||||
import styles from "./dateList.module.scss";
|
import styles from "./dateList.module.scss";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
|
const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
|
||||||
|
const today = useToday();
|
||||||
const timeFormat = "'kl.' HH:mm";
|
const timeFormat = "'kl.' HH:mm";
|
||||||
const wholeCastle = occurrence.venue?.slug === "hele-huset";
|
const wholeCastle = occurrence.venue?.slug === "hele-huset";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={`${styles.date} ${
|
className={`${styles.date} ${
|
||||||
isTodayOrFuture(occurrence.start) ? styles.future : styles.past
|
isTodayOrFuture(occurrence.start, today) ? styles.future : styles.past
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={styles.time}>
|
<div className={styles.time}>
|
||||||
<span className={styles.day}>
|
<span className={styles.day}>
|
||||||
{formatExtendedDateTime(occurrence.start, true)}
|
{formatExtendedDateTime(occurrence.start, today, true)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className={styles.hour}>
|
<span className={styles.hour}>
|
||||||
{formatDate(occurrence.start, timeFormat)}
|
{formatDate(occurrence.start, timeFormat)}
|
||||||
@@ -49,6 +51,7 @@ const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DateList = ({ event }: { event: EventFragment }) => {
|
export const DateList = ({ event }: { event: EventFragment }) => {
|
||||||
|
const today = useToday();
|
||||||
const [showAllDates, setShowAllDates] = useState(false);
|
const [showAllDates, setShowAllDates] = useState(false);
|
||||||
function toggleDates() {
|
function toggleDates() {
|
||||||
setShowAllDates(!showAllDates);
|
setShowAllDates(!showAllDates);
|
||||||
@@ -57,7 +60,7 @@ export const DateList = ({ event }: { event: EventFragment }) => {
|
|||||||
const oneOff = event.occurrences.length === 1;
|
const oneOff = event.occurrences.length === 1;
|
||||||
const showToggle = event.occurrences.length > 4;
|
const showToggle = event.occurrences.length > 4;
|
||||||
const futureOccurrences = event.occurrences
|
const futureOccurrences = event.occurrences
|
||||||
.filter((occurrence) => isTodayOrFuture(occurrence.start))
|
.filter((occurrence) => isTodayOrFuture(occurrence.start, today))
|
||||||
.sort((a, b) => compareDates(a.start, b.start));
|
.sort((a, b) => compareDates(a.start, b.start));
|
||||||
|
|
||||||
let visibleOccurrences = [];
|
let visibleOccurrences = [];
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getFilterableVenues,
|
getFilterableVenues,
|
||||||
} from "@/lib/eventFilter";
|
} from "@/lib/eventFilter";
|
||||||
import { isTodayOrFuture } from "@/lib/date";
|
import { isTodayOrFuture } from "@/lib/date";
|
||||||
|
import { useToday } from "@/components/general/Today";
|
||||||
import styles from "./eventContainer.module.scss";
|
import styles from "./eventContainer.module.scss";
|
||||||
import { formatDate, formatYearMonth } from "@/lib/date";
|
import { formatDate, formatYearMonth } from "@/lib/date";
|
||||||
import {
|
import {
|
||||||
@@ -190,7 +191,7 @@ const CalendarDay = ({
|
|||||||
<div
|
<div
|
||||||
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`}
|
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`}
|
||||||
>
|
>
|
||||||
<h3>{formatDate(parse(day, "yyyy-MM-dd", new Date()), "eeee dd.MM.")}</h3>
|
<h3>{formatDate(parse(day, "yyyy-MM-dd", new Date(0)), "eeee dd.MM.")}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<EventItem
|
<EventItem
|
||||||
@@ -234,10 +235,10 @@ const CalendarWeek = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function srOnlyYear(yearMonthString: string) {
|
function srOnlyYear(yearMonthString: string, today: Date) {
|
||||||
// formatYearMonth only shows the year for non-current years; always expose it to screen readers
|
// formatYearMonth only shows the year for non-current years; always expose it to screen readers
|
||||||
const yearMonth = new Date(yearMonthString);
|
const yearMonth = new Date(yearMonthString);
|
||||||
const isCurrentYear = yearMonth.getFullYear() === new Date().getFullYear();
|
const isCurrentYear = yearMonth.getFullYear() === today.getFullYear();
|
||||||
if (!isCurrentYear) {
|
if (!isCurrentYear) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -245,8 +246,9 @@ function srOnlyYear(yearMonthString: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
||||||
|
const today = useToday();
|
||||||
const futureSingularEvents = getSingularEvents(events).filter(
|
const futureSingularEvents = getSingularEvents(events).filter(
|
||||||
(x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start),
|
(x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start, today),
|
||||||
);
|
);
|
||||||
const eventsByDate = organizeEventsInCalendar(futureSingularEvents);
|
const eventsByDate = organizeEventsInCalendar(futureSingularEvents);
|
||||||
const yearMonths = Object.keys(eventsByDate);
|
const yearMonths = Object.keys(eventsByDate);
|
||||||
@@ -283,8 +285,8 @@ const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
|||||||
data-collapsed={!visibleYearMonths.includes(yearMonth)}
|
data-collapsed={!visibleYearMonths.includes(yearMonth)}
|
||||||
>
|
>
|
||||||
<h2 onClick={() => toggleYearMonth(yearMonth)}>
|
<h2 onClick={() => toggleYearMonth(yearMonth)}>
|
||||||
{formatYearMonth(yearMonth)}
|
{formatYearMonth(yearMonth, today)}
|
||||||
{srOnlyYear(yearMonth)}{" "}
|
{srOnlyYear(yearMonth, today)}{" "}
|
||||||
<span className={styles.eventCounter}>({eventCount})</span>
|
<span className={styles.eventCounter}>({eventCount})</span>
|
||||||
</h2>
|
</h2>
|
||||||
{eventCount === 0 && (
|
{eventCount === 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { VenueFragment } from "@/gql/graphql";
|
import { VenueFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQueryForToday } from "@/app/client";
|
||||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
|
||||||
import { EventContainer } from "@/components/events/EventContainer";
|
import { EventContainer } from "@/components/events/EventContainer";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
import {
|
import {
|
||||||
@@ -18,12 +19,7 @@ export type EventIndexViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
|
export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
|
||||||
const { data, error } = await getClient().query(
|
const data = await cmsQueryForToday(eventsOverviewQuery, {});
|
||||||
eventsOverviewQuery,
|
|
||||||
{},
|
|
||||||
cachedUntilOsloMidnight()
|
|
||||||
);
|
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
if (
|
if (
|
||||||
!data?.index ||
|
!data?.index ||
|
||||||
!data?.events?.futureEvents ||
|
!data?.events?.futureEvents ||
|
||||||
@@ -41,21 +37,22 @@ export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventIndexView({
|
// Waiting for the request makes the list render on the server per request,
|
||||||
events,
|
// with the visitor's filters from the URL already applied. Prerendered, it
|
||||||
eventCategories,
|
// would only appear after hydration and shift when the URL has filters
|
||||||
eventOrganizers,
|
async function RequestTimeEventContainer(props: EventIndexViewProps) {
|
||||||
venues,
|
await connection();
|
||||||
}: EventIndexViewProps) {
|
return <EventContainer {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventIndexView(props: EventIndexViewProps) {
|
||||||
return (
|
return (
|
||||||
<main className="site-main" id="main">
|
<main className="site-main" id="main">
|
||||||
<PageHeader heading="Dette skjer på Chateau Neuf" align="left" />
|
<PageHeader heading="Dette skjer på Chateau Neuf" align="left" />
|
||||||
<EventContainer
|
{/* the fallback reserves the list's height to keep the footer in place */}
|
||||||
events={events}
|
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>
|
||||||
eventCategories={eventCategories}
|
<RequestTimeEventContainer {...props} />
|
||||||
eventOrganizers={eventOrganizers}
|
</Suspense>
|
||||||
venues={venues}
|
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import styles from "./eventItem.module.scss";
|
import styles from "./eventItem.module.scss";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Image } from "@/components/general/Image";
|
import { Image } from "@/components/general/Image";
|
||||||
|
import { useToday } from "@/components/general/Today";
|
||||||
import {
|
import {
|
||||||
SingularEvent,
|
SingularEvent,
|
||||||
EventFragment,
|
EventFragment,
|
||||||
@@ -37,7 +38,8 @@ export const EventItem = ({
|
|||||||
imageLoading?: "eager" | "lazy";
|
imageLoading?: "eager" | "lazy";
|
||||||
imageFetchPriority?: "high" | "low" | "auto";
|
imageFetchPriority?: "high" | "low" | "auto";
|
||||||
}) => {
|
}) => {
|
||||||
const futureOccurrences = getFutureOccurrences(event);
|
const today = useToday();
|
||||||
|
const futureOccurrences = getFutureOccurrences(event, today);
|
||||||
const groupedOccurrences = groupConsecutiveDates(
|
const groupedOccurrences = groupConsecutiveDates(
|
||||||
futureOccurrences.map((occurrence) => occurrence.start)
|
futureOccurrences.map((occurrence) => occurrence.start)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { EventFragment } from "@/gql/graphql";
|
import { EventFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { EventDetails } from "@/components/events/EventDetails";
|
import { EventDetails } from "@/components/events/EventDetails";
|
||||||
import { EventHeader } from "@/components/events/EventHeader";
|
import { EventHeader } from "@/components/events/EventHeader";
|
||||||
import { BgPig } from "@/components/general/BgPig";
|
import { BgPig } from "@/components/general/BgPig";
|
||||||
@@ -28,10 +28,9 @@ export async function loadEventPageProps(args: {
|
|||||||
return { event: args.eventOverride };
|
return { event: args.eventOverride };
|
||||||
}
|
}
|
||||||
if (!args.slug) throw new Error("loadEventPageProps needs slug or eventOverride");
|
if (!args.slug) throw new Error("loadEventPageProps needs slug or eventOverride");
|
||||||
const { data, error } = await getClient().query(eventBySlugQuery, {
|
const data = await cmsQuery(eventBySlugQuery, {
|
||||||
slug: args.slug,
|
slug: args.slug,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const event = data?.event as EventFragment | undefined;
|
const event = data?.event as EventFragment | undefined;
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
return { event };
|
return { event };
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { EventItem } from "./EventItem";
|
|||||||
import styles from "./upcomingEvents.module.scss";
|
import styles from "./upcomingEvents.module.scss";
|
||||||
import { SectionHeader } from "../general/SectionHeader";
|
import { SectionHeader } from "../general/SectionHeader";
|
||||||
import { SectionFooter } from "../general/SectionFooter";
|
import { SectionFooter } from "../general/SectionFooter";
|
||||||
|
import { getToday } from "@/lib/today";
|
||||||
|
|
||||||
export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] }) => {
|
export const UpcomingEvents = async ({ events }: { events: EventListItemFragment[] }) => {
|
||||||
|
const today = await getToday();
|
||||||
const upcomingSingularEvents = sortSingularEvents(
|
const upcomingSingularEvents = sortSingularEvents(
|
||||||
getSingularEvents(events).filter((event) =>
|
getSingularEvents(events).filter((event) =>
|
||||||
isTodayOrFuture(event.occurrence.start)
|
isTodayOrFuture(event.occurrence.start, today)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const sliced = upcomingSingularEvents.slice(0, 10);
|
const sliced = upcomingSingularEvents.slice(0, 10);
|
||||||
@@ -30,7 +32,7 @@ export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] })
|
|||||||
<div key={day}>
|
<div key={day}>
|
||||||
{/*<h3>
|
{/*<h3>
|
||||||
{formatDate(
|
{formatDate(
|
||||||
parse(day, "yyyy-MM-dd", new Date()),
|
parse(day, "yyyy-MM-dd", new Date(0)),
|
||||||
"eeee dd.MM."
|
"eeee dd.MM."
|
||||||
)}
|
)}
|
||||||
</h3>*/}
|
</h3>*/}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { GenericFragment } from "@/gql/graphql";
|
import { GenericFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
import { BgPig } from "@/components/general/BgPig";
|
import { BgPig } from "@/components/general/BgPig";
|
||||||
@@ -41,10 +41,9 @@ export async function loadGenericPageProps(args: {
|
|||||||
return { page: args.pageOverride };
|
return { page: args.pageOverride };
|
||||||
}
|
}
|
||||||
if (!args.urlPath) throw new Error("loadGenericPageProps needs urlPath or pageOverride");
|
if (!args.urlPath) throw new Error("loadGenericPageProps needs urlPath or pageOverride");
|
||||||
const { data, error } = await getClient().query(genericPageByUrlPathQuery, {
|
const data = await cmsQuery(genericPageByUrlPathQuery, {
|
||||||
urlPath: args.urlPath,
|
urlPath: args.urlPath,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const page = data?.page as GenericFragment | undefined;
|
const page = data?.page as GenericFragment | undefined;
|
||||||
if (!page) return null;
|
if (!page) return null;
|
||||||
return { page };
|
return { page };
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||||
|
import { osloDay, startOfOsloDay } from "@/lib/date";
|
||||||
|
|
||||||
|
const TodayContext = createContext<string | null>(null);
|
||||||
|
|
||||||
|
function subscribeToClock(onChange: () => void) {
|
||||||
|
const interval = setInterval(onChange, 60_000);
|
||||||
|
document.addEventListener("visibilitychange", onChange);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener("visibilitychange", onChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hands the server's Oslo day to Client Components, which can't read the
|
||||||
|
// clock in a prerendered page (and would disagree with the server if they did)
|
||||||
|
export function TodayProvider({
|
||||||
|
day,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
day: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// The root layout isn't rendered again on navigation, so a tab left open
|
||||||
|
// past midnight would keep the server's day. After hydration, follow the
|
||||||
|
// browser's clock once it is ahead
|
||||||
|
const clientDay = useSyncExternalStore(subscribeToClock, osloDay, () => day);
|
||||||
|
const today = clientDay > day ? clientDay : day;
|
||||||
|
return (
|
||||||
|
<TodayContext.Provider value={today}>{children}</TodayContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start of the current Oslo day
|
||||||
|
export function useToday(): Date {
|
||||||
|
const day = useContext(TodayContext);
|
||||||
|
if (!day) {
|
||||||
|
throw new Error("useToday must be used within a TodayProvider");
|
||||||
|
}
|
||||||
|
return useMemo(() => startOfOsloDay(day), [day]);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { HomeFragment } from "@/gql/graphql";
|
import { HomeFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQueryForToday } from "@/app/client";
|
||||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
|
||||||
import { EventListItemFragment } from "@/lib/event";
|
import { EventListItemFragment } from "@/lib/event";
|
||||||
import { NewsListItemFragment } from "@/lib/news";
|
import { NewsListItemFragment } from "@/lib/news";
|
||||||
import { FeaturedEvents } from "@/components/events/FeaturedEvents";
|
import { FeaturedEvents } from "@/components/events/FeaturedEvents";
|
||||||
@@ -57,12 +56,7 @@ export type HomePageViewProps = {
|
|||||||
export async function loadHomePageProps(overrides?: {
|
export async function loadHomePageProps(overrides?: {
|
||||||
homeOverride?: HomeFragment;
|
homeOverride?: HomeFragment;
|
||||||
}): Promise<HomePageViewProps> {
|
}): Promise<HomePageViewProps> {
|
||||||
const { data, error } = await getClient().query(
|
const data = await cmsQueryForToday(homeQuery, {});
|
||||||
homeQuery,
|
|
||||||
{},
|
|
||||||
cachedUntilOsloMidnight()
|
|
||||||
);
|
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
|
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
|
||||||
if (!home) throw new Error("Failed to load /");
|
if (!home) throw new Error("Failed to load /");
|
||||||
const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[];
|
const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
getOpeningHours,
|
getOpeningHours,
|
||||||
getTodaysOpeningHoursForFunction,
|
getTodaysOpeningHoursForFunction,
|
||||||
} from "@/lib/openinghours";
|
} from "@/lib/openinghours";
|
||||||
|
import { getToday } from "@/lib/today";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Icon } from "../general/Icon";
|
import { Icon } from "../general/Icon";
|
||||||
// import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
|
// import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
|
||||||
@@ -9,7 +10,8 @@ import styles from "./footer.module.scss";
|
|||||||
import { PigPattern } from "./PigPattern";
|
import { PigPattern } from "./PigPattern";
|
||||||
|
|
||||||
async function OpeningHoursTable() {
|
async function OpeningHoursTable() {
|
||||||
const allOpeningHours = await getOpeningHours();
|
const today = await getToday();
|
||||||
|
const allOpeningHours = await getOpeningHours(today);
|
||||||
const subsections = [
|
const subsections = [
|
||||||
["glassbaren", "Glassbaren"],
|
["glassbaren", "Glassbaren"],
|
||||||
["bokcafeen", "Bokcaféen"],
|
["bokcafeen", "Bokcaféen"],
|
||||||
@@ -21,7 +23,11 @@ async function OpeningHoursTable() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{subsections.map((subsection) => {
|
{subsections.map((subsection) => {
|
||||||
const [slug, title] = subsection;
|
const [slug, title] = subsection;
|
||||||
const hours = getTodaysOpeningHoursForFunction(allOpeningHours, slug);
|
const hours = getTodaysOpeningHoursForFunction(
|
||||||
|
allOpeningHours,
|
||||||
|
slug,
|
||||||
|
today,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<tr key={slug}>
|
<tr key={slug}>
|
||||||
<td>{title}</td>
|
<td>{title}</td>
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { Icon } from "../general/Icon";
|
|||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
import { getSearchPath } from "@/lib/common";
|
import { getSearchPath } from "@/lib/common";
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = ({
|
||||||
|
christmas,
|
||||||
|
studentHat,
|
||||||
|
}: {
|
||||||
|
christmas: boolean;
|
||||||
|
studentHat: boolean;
|
||||||
|
}) => {
|
||||||
/*
|
/*
|
||||||
This component depends on the following CSS rule in base.css to prevent scrolling
|
This component depends on the following CSS rule in base.css to prevent scrolling
|
||||||
the body while the menu is open:
|
the body while the menu is open:
|
||||||
@@ -51,16 +57,6 @@ export const Header = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// let's add a santa hat during December
|
|
||||||
const isChristmas = new Date().getMonth() === 11;
|
|
||||||
|
|
||||||
// let's add the student hat during STUDiO 2026
|
|
||||||
const pigWearsHat =
|
|
||||||
new Date().getFullYear() === 2026 &&
|
|
||||||
new Date().getMonth() === 7 &&
|
|
||||||
new Date().getDate() >= 10 &&
|
|
||||||
new Date().getDate() <= 16;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
@@ -70,7 +66,7 @@ export const Header = () => {
|
|||||||
data-small={!isInView}
|
data-small={!isInView}
|
||||||
>
|
>
|
||||||
<Link href="/" aria-label="Hjem">
|
<Link href="/" aria-label="Hjem">
|
||||||
<Logo christmas={isChristmas} studentHat={pigWearsHat} />
|
<Logo christmas={christmas} studentHat={studentHat} />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className={styles.siteMenu}>
|
<nav className={styles.siteMenu}>
|
||||||
<ul className={styles.mainMenu}>
|
<ul className={styles.mainMenu}>
|
||||||
@@ -193,7 +189,7 @@ export const Header = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className={styles.headerBar} aria-hidden>
|
<div className={styles.headerBar} aria-hidden>
|
||||||
<Link href="/" aria-label="Hjem" tabIndex={-1}>
|
<Link href="/" aria-label="Hjem" tabIndex={-1}>
|
||||||
<LogoIcon face="left" christmas={isChristmas} />
|
<LogoIcon face="left" christmas={christmas} />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className={styles.siteMenu}>
|
<nav className={styles.siteMenu}>
|
||||||
<ul className={styles.mainMenu}>
|
<ul className={styles.mainMenu}>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getToday } from "@/lib/today";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
|
||||||
|
// The logo's hats depend on today's date, which the prerendered Header
|
||||||
|
// (a Client Component) can't read itself
|
||||||
|
export async function SeasonalHeader() {
|
||||||
|
const today = await getToday();
|
||||||
|
|
||||||
|
// let's add a santa hat during December
|
||||||
|
const christmas = today.getMonth() === 11;
|
||||||
|
|
||||||
|
// let's add the student hat during STUDiO 2026
|
||||||
|
const studentHat =
|
||||||
|
today.getFullYear() === 2026 &&
|
||||||
|
today.getMonth() === 7 &&
|
||||||
|
today.getDate() >= 10 &&
|
||||||
|
today.getDate() <= 16;
|
||||||
|
|
||||||
|
return <Header christmas={christmas} studentHat={studentHat} />;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
import { NewsList } from "@/components/news/NewsList";
|
import { NewsList } from "@/components/news/NewsList";
|
||||||
import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news";
|
import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news";
|
||||||
@@ -11,8 +11,7 @@ export type NewsIndexViewProps = {
|
|||||||
export async function loadNewsIndexProps(overrides?: {
|
export async function loadNewsIndexProps(overrides?: {
|
||||||
indexOverride?: NewsIndexFragment;
|
indexOverride?: NewsIndexFragment;
|
||||||
}): Promise<NewsIndexViewProps> {
|
}): Promise<NewsIndexViewProps> {
|
||||||
const { data, error } = await getClient().query(newsQuery, {});
|
const data = await cmsQuery(newsQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
|
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
|
||||||
if (!index) throw new Error("Failed to load /aktuelt");
|
if (!index) throw new Error("Failed to load /aktuelt");
|
||||||
const news = (data?.news ?? []) as NewsListItemFragment[];
|
const news = (data?.news ?? []) as NewsListItemFragment[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { NewsFragment } from "@/gql/graphql";
|
import { NewsFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { Breadcrumb } from "@/components/general/Breadcrumb";
|
import { Breadcrumb } from "@/components/general/Breadcrumb";
|
||||||
import { ImageFigure } from "@/components/general/Image";
|
import { ImageFigure } from "@/components/general/Image";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
@@ -27,10 +27,9 @@ export async function loadNewsPageProps(args: {
|
|||||||
return { news: args.newsOverride };
|
return { news: args.newsOverride };
|
||||||
}
|
}
|
||||||
if (!args.slug) throw new Error("loadNewsPageProps needs slug or newsOverride");
|
if (!args.slug) throw new Error("loadNewsPageProps needs slug or newsOverride");
|
||||||
const { data, error } = await getClient().query(newsBySlugQuery, {
|
const data = await cmsQuery(newsBySlugQuery, {
|
||||||
slug: args.slug,
|
slug: args.slug,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const news = data?.news as NewsFragment | undefined;
|
const news = data?.news as NewsFragment | undefined;
|
||||||
if (!news) return null;
|
if (!news) return null;
|
||||||
return { news };
|
return { news };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { type SponsorFragment, type SponsorsPageFragment } from "@/gql/graphql";
|
import { type SponsorFragment, type SponsorsPageFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
import { SponsorList } from "@/components/sponsor/SponsorList";
|
import { SponsorList } from "@/components/sponsor/SponsorList";
|
||||||
@@ -41,8 +41,7 @@ export async function loadSponsorsPageProps(overrides?: {
|
|||||||
if (overrides?.pageOverride) {
|
if (overrides?.pageOverride) {
|
||||||
return { page: overrides.pageOverride };
|
return { page: overrides.pageOverride };
|
||||||
}
|
}
|
||||||
const { data, error } = await getClient().query(sponsorsPageQuery, {});
|
const data = await cmsQuery(sponsorsPageQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const page = data?.page as SponsorsPageFragment | undefined;
|
const page = data?.page as SponsorsPageFragment | undefined;
|
||||||
if (!page) throw new Error("Failed to load /sponsorer");
|
if (!page) throw new Error("Failed to load /sponsorer");
|
||||||
return { page };
|
return { page };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { type StudioFragment } from "@/gql/graphql";
|
import { type StudioFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
import { BgPig } from "@/components/general/BgPig";
|
import { BgPig } from "@/components/general/BgPig";
|
||||||
import { StudioHeader } from "@/components/studio/StudioHeader";
|
import { StudioHeader } from "@/components/studio/StudioHeader";
|
||||||
@@ -45,8 +45,7 @@ export async function loadStudioPageProps(overrides?: {
|
|||||||
if (overrides?.pageOverride) {
|
if (overrides?.pageOverride) {
|
||||||
return { page: overrides.pageOverride };
|
return { page: overrides.pageOverride };
|
||||||
}
|
}
|
||||||
const { data, error } = await getClient().query(studioPageQuery, {});
|
const data = await cmsQuery(studioPageQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const page = data?.page as StudioFragment | undefined;
|
const page = data?.page as StudioFragment | undefined;
|
||||||
if (!page) throw new Error("Failed to load /studio");
|
if (!page) throw new Error("Failed to load /studio");
|
||||||
return { page };
|
return { page };
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { fromZonedTime } from "date-fns-tz";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Icon } from "../general/Icon";
|
import { Icon } from "../general/Icon";
|
||||||
import styles from "./studioShortcut.module.scss";
|
import styles from "./studioShortcut.module.scss";
|
||||||
|
import { startOfOsloDay } from "@/lib/date";
|
||||||
|
import { getToday } from "@/lib/today";
|
||||||
|
|
||||||
// Hidden from this date
|
// Hidden from this date
|
||||||
// Note that home page revalidation happens at midnight
|
// Note that home page revalidation happens at midnight
|
||||||
const showUntil = fromZonedTime("2026-08-16T00:00:00", "Europe/Oslo");
|
const showUntil = startOfOsloDay("2026-08-16");
|
||||||
|
|
||||||
export const StudioShortcut = () => {
|
export const StudioShortcut = async () => {
|
||||||
if (new Date() >= showUntil) return null;
|
if ((await getToday()) >= showUntil) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.studioShortcut}>
|
<div className={styles.studioShortcut}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { VenueFragment, VenueIndexFragment } from "@/gql/graphql";
|
import { VenueFragment, VenueIndexFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
import { VenueList } from "@/components/venues/VenueList";
|
import { VenueList } from "@/components/venues/VenueList";
|
||||||
@@ -77,8 +77,7 @@ export type VenueIndexViewProps = {
|
|||||||
export async function loadVenueIndexProps(overrides?: {
|
export async function loadVenueIndexProps(overrides?: {
|
||||||
indexOverride?: VenueIndexFragment;
|
indexOverride?: VenueIndexFragment;
|
||||||
}): Promise<VenueIndexViewProps> {
|
}): Promise<VenueIndexViewProps> {
|
||||||
const { data, error } = await getClient().query(venueIndexQuery, {});
|
const data = await cmsQuery(venueIndexQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const index = overrides?.indexOverride ?? (data?.index as VenueIndexFragment | undefined);
|
const index = overrides?.indexOverride ?? (data?.index as VenueIndexFragment | undefined);
|
||||||
if (!index) throw new Error("Failed to load /lokaler");
|
if (!index) throw new Error("Failed to load /lokaler");
|
||||||
const venues = (data?.venues ?? []) as VenueFragment[];
|
const venues = (data?.venues ?? []) as VenueFragment[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { VenueFragment } from "@/gql/graphql";
|
import { VenueFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { ImageSliderBlockFragmentDefinition } from "@/components/blocks/ImageSliderBlock";
|
import { ImageSliderBlockFragmentDefinition } from "@/components/blocks/ImageSliderBlock";
|
||||||
import { Breadcrumb } from "@/components/general/Breadcrumb";
|
import { Breadcrumb } from "@/components/general/Breadcrumb";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
@@ -32,10 +32,9 @@ export async function loadVenuePageProps(args: {
|
|||||||
return { venue: args.venueOverride };
|
return { venue: args.venueOverride };
|
||||||
}
|
}
|
||||||
if (!args.slug) throw new Error("loadVenuePageProps needs slug or venueOverride");
|
if (!args.slug) throw new Error("loadVenuePageProps needs slug or venueOverride");
|
||||||
const { data, error } = await getClient().query(venueBySlugQuery, {
|
const data = await cmsQuery(venueBySlugQuery, {
|
||||||
slug: args.slug,
|
slug: args.slug,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const venue = data?.venue as VenueFragment | undefined;
|
const venue = data?.venue as VenueFragment | undefined;
|
||||||
if (!venue) return null;
|
if (!venue) return null;
|
||||||
return { venue };
|
return { venue };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { graphql } from "@/gql";
|
import { graphql } from "@/gql";
|
||||||
import { VenueFragment, VenueRentalIndexFragment } from "@/gql/graphql";
|
import { VenueFragment, VenueRentalIndexFragment } from "@/gql/graphql";
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { BgPig } from "@/components/general/BgPig";
|
import { BgPig } from "@/components/general/BgPig";
|
||||||
import { PageContent } from "@/components/general/PageContent";
|
import { PageContent } from "@/components/general/PageContent";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
@@ -42,8 +42,7 @@ export type VenueRentalIndexViewProps = {
|
|||||||
export async function loadVenueRentalIndexProps(overrides?: {
|
export async function loadVenueRentalIndexProps(overrides?: {
|
||||||
indexOverride?: VenueRentalIndexFragment;
|
indexOverride?: VenueRentalIndexFragment;
|
||||||
}): Promise<VenueRentalIndexViewProps> {
|
}): Promise<VenueRentalIndexViewProps> {
|
||||||
const { data, error } = await getClient().query(venueRentalIndexQuery, {});
|
const data = await cmsQuery(venueRentalIndexQuery, {});
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
const index =
|
const index =
|
||||||
overrides?.indexOverride ?? (data?.index as VenueRentalIndexFragment | undefined);
|
overrides?.indexOverride ?? (data?.index as VenueRentalIndexFragment | undefined);
|
||||||
if (!index) throw new Error("Failed to load /utleie");
|
if (!index) throw new Error("Failed to load /utleie");
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ type Documents = {
|
|||||||
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": typeof types.OpeningHoursSetFragmentDoc,
|
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": typeof types.OpeningHoursSetFragmentDoc,
|
||||||
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": typeof types.OpeningHoursRangeBlockFragmentDoc,
|
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": typeof types.OpeningHoursRangeBlockFragmentDoc,
|
||||||
"\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n": typeof types.OpeningHoursWeekBlockFragmentDoc,
|
"\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n": typeof types.OpeningHoursWeekBlockFragmentDoc,
|
||||||
|
"\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n": typeof types.LatestPageSlugDocument,
|
||||||
};
|
};
|
||||||
const documents: Documents = {
|
const documents: Documents = {
|
||||||
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": types.AllGenericSlugsDocument,
|
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": types.AllGenericSlugsDocument,
|
||||||
@@ -148,6 +149,7 @@ const documents: Documents = {
|
|||||||
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": types.OpeningHoursSetFragmentDoc,
|
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": types.OpeningHoursSetFragmentDoc,
|
||||||
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": types.OpeningHoursRangeBlockFragmentDoc,
|
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": types.OpeningHoursRangeBlockFragmentDoc,
|
||||||
"\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n": types.OpeningHoursWeekBlockFragmentDoc,
|
"\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n": types.OpeningHoursWeekBlockFragmentDoc,
|
||||||
|
"\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n": types.LatestPageSlugDocument,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -428,6 +430,10 @@ export function graphql(source: "\n fragment OpeningHoursRangeBlock on OpeningH
|
|||||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
*/
|
*/
|
||||||
export function graphql(source: "\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n"): (typeof documents)["\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n"];
|
export function graphql(source: "\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n"): (typeof documents)["\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n"];
|
||||||
|
/**
|
||||||
|
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
|
*/
|
||||||
|
export function graphql(source: "\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n"): (typeof documents)["\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n"];
|
||||||
|
|
||||||
export function graphql(source: string) {
|
export function graphql(source: string) {
|
||||||
return (documents as any)[source] ?? {};
|
return (documents as any)[source] ?? {};
|
||||||
|
|||||||
+25
-1
@@ -2037,6 +2037,29 @@ export type OpeningHoursWeekBlockFragment = { monday:
|
|||||||
| Record<PropertyKey, never>
|
| Record<PropertyKey, never>
|
||||||
| null } & { ' $fragmentName'?: 'OpeningHoursWeekBlockFragment' };
|
| null } & { ' $fragmentName'?: 'OpeningHoursWeekBlockFragment' };
|
||||||
|
|
||||||
|
export type LatestPageSlugQueryVariables = Exact<{
|
||||||
|
contentType: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type LatestPageSlugQuery = { pages: Array<
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
| { id: string | null, slug: string }
|
||||||
|
> };
|
||||||
|
|
||||||
export const RichTextBlockFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RichTextBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RichTextBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rawValue"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]} as unknown as DocumentNode<RichTextBlockFragment, unknown>;
|
export const RichTextBlockFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RichTextBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RichTextBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rawValue"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]} as unknown as DocumentNode<RichTextBlockFragment, unknown>;
|
||||||
export const ImageFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}}]} as unknown as DocumentNode<ImageFragment, unknown>;
|
export const ImageFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}}]} as unknown as DocumentNode<ImageFragment, unknown>;
|
||||||
export const ImageWithTextBlockFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ImageWithTextBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ImageWithTextBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"imageFormat"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}}]} as unknown as DocumentNode<ImageWithTextBlockFragment, unknown>;
|
export const ImageWithTextBlockFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ImageWithTextBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ImageWithTextBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"imageFormat"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}}]} as unknown as DocumentNode<ImageWithTextBlockFragment, unknown>;
|
||||||
@@ -2102,4 +2125,5 @@ export const EventIndexMetadataDocument = {"kind":"Document","definitions":[{"ki
|
|||||||
export const FutureEventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"futureEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"eventIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventIndex"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"events"},"name":{"kind":"Name","value":"eventIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"futureEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventOverviewItem"}}]}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"eventCategories"},"name":{"kind":"Name","value":"eventCategories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventCategory"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventCategory"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"eventOrganizers"},"name":{"kind":"Name","value":"eventOrganizers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventOrganizer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventOrganizer"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"venues"},"name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"StringValue","value":"venues.VenuePage","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"VenuePage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"preposition"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventCategory"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventCategory"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"pig"}},{"kind":"Field","name":{"kind":"Name","value":"showInFilters"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventOrganizer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventOrganizer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}},{"kind":"Field","name":{"kind":"Name","value":"association"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AssociationPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventIndex"},"typeCondition":{"kind":"NamLine truncated
|
export const FutureEventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"futureEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"eventIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventIndex"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"events"},"name":{"kind":"Name","value":"eventIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"futureEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventOverviewItem"}}]}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"eventCategories"},"name":{"kind":"Name","value":"eventCategories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventCategory"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventCategory"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"eventOrganizers"},"name":{"kind":"Name","value":"eventOrganizers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventOrganizer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventOrganizer"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"venues"},"name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"StringValue","value":"venues.VenuePage","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"VenuePage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"preposition"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventCategory"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventCategory"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"pig"}},{"kind":"Field","name":{"kind":"Name","value":"showInFilters"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventOrganizer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventOrganizer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}},{"kind":"Field","name":{"kind":"Name","value":"association"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AssociationPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventIndex"},"typeCondition":{"kind":"NamLine truncated
|
||||||
export const NewsIndexMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"newsIndexMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"newsIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsIndex"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsIndex"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"seoTitle"}},{"kind":"Field","name":{"kind":"Name","value":"searchDescription"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"lead"}}]}}]} as unknown as DocumentNode<NewsIndexMetadataQuery, NewsIndexMetadataQueryVariables>;
|
export const NewsIndexMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"newsIndexMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"newsIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsIndex"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsIndex"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"seoTitle"}},{"kind":"Field","name":{"kind":"Name","value":"searchDescription"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"lead"}}]}}]} as unknown as DocumentNode<NewsIndexMetadataQuery, NewsIndexMetadataQueryVariables>;
|
||||||
export const NewsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"news"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"newsIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsIndex"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"news"},"name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"StringValue","value":"news.NewsPage","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"StringValue","value":"-first_published_at","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsListItem"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsIndex"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"seoTitle"}},{"kind":"Field","name":{"kind":"Name","value":"searchDescription"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"lead"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"firstPublishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"featuredImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}}]}}]} as unknown as DocumentNode<NewsQuery, NewsQueryVariables>;
|
export const NewsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"news"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"newsIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsIndex"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"news"},"name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"StringValue","value":"news.NewsPage","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"StringValue","value":"-first_published_at","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsListItem"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsIndex"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"seoTitle"}},{"kind":"Field","name":{"kind":"Name","value":"searchDescription"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"lead"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"firstPublishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"featuredImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}}]}}]} as unknown as DocumentNode<NewsQuery, NewsQueryVariables>;
|
||||||
export const OpeningHoursSetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursSet"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursRangeBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timeFrom"}},{"kind":"Field","name":{"kind":"Name","value":"timeTo"}},{"kind":"Field","name":{"kind":"Name","value":"custom"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursWeekBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tuesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"wednesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"thursday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"friday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"saturday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"sunday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursSet"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursSet"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveFrom"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveTo"}},{"kind":"Field","name":{"kind":"Name","value":"announcement"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"function"}},{"kind":"Field","name":{"kind":"Name","value":"week"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}}]}}]}}]}}]}}]} as unknown as DocumentNode<OpeningHoursSetsQuery, OpeningHoursSetsQueryVariables>;
|
export const OpeningHoursSetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursSet"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursRangeBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timeFrom"}},{"kind":"Field","name":{"kind":"Name","value":"timeTo"}},{"kind":"Field","name":{"kind":"Name","value":"custom"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursWeekBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tuesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"wednesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"thursday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"friday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"saturday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"sunday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursSet"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursSet"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveFrom"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveTo"}},{"kind":"Field","name":{"kind":"Name","value":"announcement"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"function"}},{"kind":"Field","name":{"kind":"Name","value":"week"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}}]}}]}}]}}]}}]} as unknown as DocumentNode<OpeningHoursSetsQuery, OpeningHoursSetsQueryVariables>;
|
||||||
|
export const LatestPageSlugDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"latestPageSlug"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contentType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contentType"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"StringValue","value":"-first_published_at","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]} as unknown as DocumentNode<LatestPageSlugQuery, LatestPageSlugQueryVariables>;
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { internalRevalidateSecret } from "@/lib/internalRevalidate";
|
||||||
|
import { register } from "./instrumentation.ts";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
const purgedTags = () =>
|
||||||
|
fetchMock.mock.calls.map(([url]) => new URL(String(url)).searchParams.get("tag"));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// 23:59:50 in Oslo (CEST)
|
||||||
|
vi.setSystemTime(new Date("2026-07-07T21:59:50Z"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
vi.stubEnv("PORT", "4321");
|
||||||
|
vi.stubEnv("HOSTNAME", "0.0.0.0");
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// register() and let the startup purge go through
|
||||||
|
async function start() {
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("at startup", () => {
|
||||||
|
it("purges both tags", async () => {
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(purgedTags()).toEqual(["cms", "today"]);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toBe("http://127.0.0.1:4321/api/revalidate?tag=cms");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
// authenticated with the secret the route accepts for internal calls
|
||||||
|
expect(init.headers["x-revalidate-secret"]).toBe(internalRevalidateSecret());
|
||||||
|
expect(internalRevalidateSecret()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps trying until the server is listening", async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||||
|
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||||
|
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(2_500);
|
||||||
|
|
||||||
|
expect(purgedTags()).toEqual(["cms", "cms", "cms", "today"]);
|
||||||
|
// expected while the server starts: not worth an error in the log
|
||||||
|
expect(console.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts over when only the first of the two purges went through", async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||||
|
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_500);
|
||||||
|
|
||||||
|
expect(purgedTags()).toEqual(["cms", "today", "cms", "today"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["an IPv4 address", "10.0.0.5", "http://10.0.0.5:4321"],
|
||||||
|
["an IPv6 address", "::1", "http://[::1]:4321"],
|
||||||
|
["the IPv6 wildcard", "::", "http://127.0.0.1:4321"],
|
||||||
|
["nothing", "", "http://127.0.0.1:4321"],
|
||||||
|
])("reaches a server bound to %s", async (_what, hostname, origin) => {
|
||||||
|
vi.stubEnv("HOSTNAME", hostname);
|
||||||
|
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||||
|
`${origin}/api/revalidate?tag=cms`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("at Oslo midnight", () => {
|
||||||
|
it("purges the today tag one second past, and once more after that", async () => {
|
||||||
|
await start();
|
||||||
|
|
||||||
|
// 23:59:59: not yet
|
||||||
|
await vi.advanceTimersByTimeAsync(9_000);
|
||||||
|
expect(purgedTags()).toEqual([]);
|
||||||
|
|
||||||
|
// 00:00:01
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
expect(purgedTags()).toEqual(["today"]);
|
||||||
|
|
||||||
|
// a page rendered across the first purge would have survived it
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
expect(purgedTags()).toEqual(["today", "today"]);
|
||||||
|
|
||||||
|
// then nothing for the rest of the day
|
||||||
|
await vi.advanceTimersByTimeAsync(23 * 3600 * 1000);
|
||||||
|
expect(purgedTags()).toEqual(["today", "today"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does so every night", async () => {
|
||||||
|
await start();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3 * 24 * 3600 * 1000);
|
||||||
|
|
||||||
|
expect(purgedTags()).toEqual(Array(6).fill("today"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches up when timers stood still over midnight", async () => {
|
||||||
|
await start();
|
||||||
|
|
||||||
|
// a paused host: the clock moves on, the pending timer does not fire
|
||||||
|
vi.setSystemTime(new Date("2026-07-07T22:10:00Z")); // 00:10 in Oslo
|
||||||
|
expect(purgedTags()).toEqual([]);
|
||||||
|
|
||||||
|
// the next regular check notices the new day
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
expect(purgedTags()).toEqual(["today"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a failed purge at the next check, and says so", async () => {
|
||||||
|
await start();
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(11_000); // 00:00:01, fails
|
||||||
|
expect(purgedTags()).toEqual(["today"]);
|
||||||
|
expect(console.error).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000); // succeeds
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000); // the follow-up purge
|
||||||
|
expect(purgedTags()).toEqual(["today", "today", "today"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("register", () => {
|
||||||
|
it.each([
|
||||||
|
["during next build", { NEXT_PHASE: "phase-production-build" }],
|
||||||
|
["in next dev", { NODE_ENV: "development" }],
|
||||||
|
["on the edge runtime", { NEXT_RUNTIME: "edge" }],
|
||||||
|
])("does nothing %s", async (_when, env) => {
|
||||||
|
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
|
||||||
|
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(48 * 3600 * 1000);
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { osloDay } from "@/lib/date";
|
||||||
|
import {
|
||||||
|
purgeOwnCache,
|
||||||
|
setInternalRevalidateSecret,
|
||||||
|
} from "@/lib/internalRevalidate";
|
||||||
|
import {
|
||||||
|
CMS_CACHE_TAG,
|
||||||
|
TODAY_CACHE_TAG,
|
||||||
|
secondsUntilOsloMidnight,
|
||||||
|
} from "@/lib/revalidation";
|
||||||
|
|
||||||
|
// How often the clock is checked. Timers alone can't be trusted to fire at
|
||||||
|
// midnight: they stand still while a host or VM is paused
|
||||||
|
const CHECK_INTERVAL_MS = 30_000;
|
||||||
|
// Until the first purge has gone through; the server isn't listening yet
|
||||||
|
// when register() runs
|
||||||
|
const STARTUP_RETRY_MS = 1_000;
|
||||||
|
|
||||||
|
function keepCacheCurrent() {
|
||||||
|
// the Oslo day the cache was last purged for; undefined until the startup
|
||||||
|
// purge has gone through
|
||||||
|
let purgedDay: string | undefined;
|
||||||
|
let repeatTodayPurge = false;
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const day = osloDay();
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
if (purgedDay === undefined) {
|
||||||
|
// Pages prerendered by the build, or left on disk by an earlier run,
|
||||||
|
// may predate CMS edits and midnights this process never saw
|
||||||
|
error =
|
||||||
|
(await purgeOwnCache(CMS_CACHE_TAG)) ??
|
||||||
|
(await purgeOwnCache(TODAY_CACHE_TAG));
|
||||||
|
if (!error) purgedDay = day;
|
||||||
|
} else if (day !== purgedDay) {
|
||||||
|
error = await purgeOwnCache(TODAY_CACHE_TAG);
|
||||||
|
if (!error) {
|
||||||
|
purgedDay = day;
|
||||||
|
repeatTodayPurge = true;
|
||||||
|
}
|
||||||
|
} else if (repeatTodayPurge) {
|
||||||
|
// Next stamps a page when its render finishes, so a page that was
|
||||||
|
// being rendered across the purge looks newer than it and survives.
|
||||||
|
// A second purge one interval later catches those
|
||||||
|
error = await purgeOwnCache(TODAY_CACHE_TAG);
|
||||||
|
if (!error) repeatTodayPurge = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
failures += 1;
|
||||||
|
// at startup, connection errors are expected for the first moments
|
||||||
|
if (failures === 1 ? purgedDay !== undefined : failures % 60 === 0) {
|
||||||
|
console.error(`Purging the page cache keeps failing: ${error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failures = 0;
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
const delay =
|
||||||
|
purgedDay === undefined
|
||||||
|
? STARTUP_RETRY_MS
|
||||||
|
: // one second past midnight, so the purge can't land on the old day
|
||||||
|
Math.min(CHECK_INTERVAL_MS, (secondsUntilOsloMidnight() + 1) * 1000);
|
||||||
|
setTimeout(check, delay).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
void check();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
// only in the running production server: not during `next build`, in
|
||||||
|
// `next dev` (nothing is kept between requests there) or on the edge
|
||||||
|
if (
|
||||||
|
process.env.NEXT_RUNTIME !== "nodejs" ||
|
||||||
|
process.env.NODE_ENV !== "production" ||
|
||||||
|
process.env.NEXT_PHASE === "phase-production-build"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInternalRevalidateSecret(crypto.randomUUID());
|
||||||
|
keepCacheCurrent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const SRC = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
|
||||||
|
function sourceFiles(dir: string): string[] {
|
||||||
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return entry.name === "gql" ? [] : sourceFiles(path);
|
||||||
|
return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)
|
||||||
|
? [path]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("use cache scopes", () => {
|
||||||
|
// A scope without one of the helpers falls back to Next's default lifetime
|
||||||
|
// and carries no tag, so the CMS webhook can't purge it
|
||||||
|
it("all call one of the cmsCache helpers right after the directive", () => {
|
||||||
|
const directive = /^\s*["']use cache["'];?\s*$/;
|
||||||
|
const offenders: string[] = [];
|
||||||
|
let scopes = 0;
|
||||||
|
for (const file of sourceFiles(SRC)) {
|
||||||
|
const lines = readFileSync(file, "utf8").split("\n");
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (!directive.test(line)) return;
|
||||||
|
scopes += 1;
|
||||||
|
if (!/^\s*(cmsCache|cmsCacheForToday|todayCache)\(\);\s*$/.test(lines[i + 1] ?? "")) {
|
||||||
|
offenders.push(`${file.slice(SRC.length)}:${i + 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
// the scan itself must keep finding the scopes
|
||||||
|
expect(scopes).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CMS_CACHE_LIFE,
|
||||||
|
CMS_CACHE_TAG,
|
||||||
|
TODAY_CACHE_TAG,
|
||||||
|
} from "@/lib/revalidation";
|
||||||
|
|
||||||
|
// Every "use cache" scope starts with one of these. A scope without a tag
|
||||||
|
// could never be purged
|
||||||
|
|
||||||
|
// For content that only changes when it is edited in the CMS
|
||||||
|
export function cmsCache() {
|
||||||
|
cacheTag(CMS_CACHE_TAG);
|
||||||
|
cacheLife(CMS_CACHE_LIFE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For CMS content that depends on what day it is: also purged at Oslo
|
||||||
|
// midnight
|
||||||
|
export function cmsCacheForToday() {
|
||||||
|
cacheTag(CMS_CACHE_TAG, TODAY_CACHE_TAG);
|
||||||
|
cacheLife(CMS_CACHE_LIFE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the current day itself. Not tagged as CMS content: an edit in the CMS
|
||||||
|
// must not restart the lifetime that heals a missed midnight purge
|
||||||
|
export function todayCache() {
|
||||||
|
cacheTag(TODAY_CACHE_TAG);
|
||||||
|
cacheLife(CMS_CACHE_LIFE);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
formatNorwegianPhoneNumber,
|
formatNorwegianPhoneNumber,
|
||||||
formatPhoneE164,
|
formatPhoneE164,
|
||||||
getSearchPath,
|
getSearchPath,
|
||||||
randomElement,
|
seededElement,
|
||||||
stripHtml,
|
stripHtml,
|
||||||
stripWhitespace,
|
stripWhitespace,
|
||||||
unique,
|
unique,
|
||||||
@@ -22,11 +22,25 @@ describe("getSearchPath", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("randomElement", () => {
|
describe("seededElement", () => {
|
||||||
it("picks a member, undefined for empty", () => {
|
it("picks a member, undefined for empty", () => {
|
||||||
expect(randomElement([])).toBeUndefined();
|
expect(seededElement([], "x")).toBeUndefined();
|
||||||
expect(randomElement(["a"])).toBe("a");
|
expect(seededElement(["a"], "x")).toBe("a");
|
||||||
expect([1, 2, 3]).toContain(randomElement([1, 2, 3]));
|
expect([1, 2, 3]).toContain(seededElement([1, 2, 3], "x"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the same member for the same seed", () => {
|
||||||
|
const picks = new Set(
|
||||||
|
Array.from({ length: 20 }, () => seededElement([1, 2, 3, 4, 5], "event-42"))
|
||||||
|
);
|
||||||
|
expect(picks.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spreads different seeds over the members", () => {
|
||||||
|
const picks = new Set(
|
||||||
|
Array.from({ length: 50 }, (_, i) => seededElement([1, 2, 3], `event-${i}`))
|
||||||
|
);
|
||||||
|
expect(picks.size).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ export function getSearchPath(query: string): string {
|
|||||||
return `/sok?${params.toString()}`;
|
return `/sok?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function randomElement(array: any[]): any | undefined {
|
// Picks the same element for the same seed every time. Rendering code can't
|
||||||
return array.length
|
// use Math.random(): prerendering rejects it, and the pick would change
|
||||||
? array[Math.floor(Math.random() * array.length)]
|
// between the server render and the next regeneration
|
||||||
: undefined;
|
export function seededElement<T>(array: T[], seed: string): T | undefined {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return array.length ? array[hash % array.length] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unique<T>(array: any[]): any[] {
|
export function unique<T>(array: any[]): any[] {
|
||||||
|
|||||||
+22
-17
@@ -10,7 +10,8 @@ import {
|
|||||||
groupConsecutiveDates,
|
groupConsecutiveDates,
|
||||||
isConsecutiveDays,
|
isConsecutiveDays,
|
||||||
isTodayOrFuture,
|
isTodayOrFuture,
|
||||||
startOfTodayInOslo,
|
osloDay,
|
||||||
|
startOfOsloDay,
|
||||||
toLocalTime,
|
toLocalTime,
|
||||||
} from "./date.ts";
|
} from "./date.ts";
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// what getToday()/useToday() hand to the helpers
|
||||||
|
const today = () => startOfOsloDay(osloDay());
|
||||||
|
|
||||||
describe("toLocalTime", () => {
|
describe("toLocalTime", () => {
|
||||||
it("converts UTC to Oslo wall clock", () => {
|
it("converts UTC to Oslo wall clock", () => {
|
||||||
expect(toLocalTime("2026-07-07T18:00:00Z").getHours()).toBe(20);
|
expect(toLocalTime("2026-07-07T18:00:00Z").getHours()).toBe(20);
|
||||||
@@ -31,15 +35,16 @@ describe("toLocalTime", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("startOfTodayInOslo", () => {
|
describe("osloDay + startOfOsloDay", () => {
|
||||||
it("returns the Oslo date even when the server runs in UTC", () => {
|
it("returns the Oslo date even when the server runs in UTC", () => {
|
||||||
const tz = process.env.TZ;
|
const tz = process.env.TZ;
|
||||||
process.env.TZ = "UTC";
|
process.env.TZ = "UTC";
|
||||||
try {
|
try {
|
||||||
vi.setSystemTime(new Date("2026-07-07T22:20:00Z"));
|
vi.setSystemTime(new Date("2026-07-07T22:20:00Z"));
|
||||||
const today = startOfTodayInOslo();
|
expect(osloDay()).toBe("2026-07-08");
|
||||||
expect(today.getDate()).toBe(8);
|
const start = startOfOsloDay(osloDay());
|
||||||
expect(today.getHours()).toBe(0);
|
expect(start.getDate()).toBe(8);
|
||||||
|
expect(start.getHours()).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.TZ = tz;
|
process.env.TZ = tz;
|
||||||
}
|
}
|
||||||
@@ -57,42 +62,42 @@ describe("formatDate", () => {
|
|||||||
|
|
||||||
describe("formatYearMonth", () => {
|
describe("formatYearMonth", () => {
|
||||||
it("omits year for the current year", () => {
|
it("omits year for the current year", () => {
|
||||||
expect(formatYearMonth("2026-07")).toBe("juli");
|
expect(formatYearMonth("2026-07", today())).toBe("juli");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes year for other years", () => {
|
it("includes year for other years", () => {
|
||||||
expect(formatYearMonth("2025-11")).toBe("november 2025");
|
expect(formatYearMonth("2025-11", today())).toBe("november 2025");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatExtendedDateTime", () => {
|
describe("formatExtendedDateTime", () => {
|
||||||
it("omits year in the current year", () => {
|
it("omits year in the current year", () => {
|
||||||
expect(formatExtendedDateTime("2026-07-07T18:00:00Z")).toBe(
|
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", today())).toBe(
|
||||||
"tirsdag 7. juli kl. 20:00"
|
"tirsdag 7. juli kl. 20:00"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes year for other years", () => {
|
it("includes year for other years", () => {
|
||||||
expect(formatExtendedDateTime("2025-11-01T12:00:00Z")).toBe(
|
expect(formatExtendedDateTime("2025-11-01T12:00:00Z", today())).toBe(
|
||||||
"lørdag 1. november 2025 kl. 13:00"
|
"lørdag 1. november 2025 kl. 13:00"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports dateOnly and alwaysIncludeYear", () => {
|
it("supports dateOnly and alwaysIncludeYear", () => {
|
||||||
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", true)).toBe(
|
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", today(), true)).toBe(
|
||||||
"tirsdag 7. juli"
|
"tirsdag 7. juli"
|
||||||
);
|
);
|
||||||
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", false, true)).toBe(
|
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", today(), false, true)).toBe(
|
||||||
"tirsdag 7. juli 2026 kl. 20:00"
|
"tirsdag 7. juli 2026 kl. 20:00"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the correct UTC offset around the DST fall-back", () => {
|
it("uses the correct UTC offset around the DST fall-back", () => {
|
||||||
// DST ends 01:00Z on 2026-10-25: before it Oslo is UTC+2, after UTC+1
|
// DST ends 01:00Z on 2026-10-25: before it Oslo is UTC+2, after UTC+1
|
||||||
expect(formatExtendedDateTime("2026-10-24T23:30:00Z")).toBe(
|
expect(formatExtendedDateTime("2026-10-24T23:30:00Z", today())).toBe(
|
||||||
"søndag 25. oktober kl. 01:30"
|
"søndag 25. oktober kl. 01:30"
|
||||||
);
|
);
|
||||||
expect(formatExtendedDateTime("2026-10-25T01:30:00Z")).toBe(
|
expect(formatExtendedDateTime("2026-10-25T01:30:00Z", today())).toBe(
|
||||||
"søndag 25. oktober kl. 02:30"
|
"søndag 25. oktober kl. 02:30"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -100,14 +105,14 @@ describe("formatExtendedDateTime", () => {
|
|||||||
|
|
||||||
describe("isTodayOrFuture", () => {
|
describe("isTodayOrFuture", () => {
|
||||||
it("is false for yesterday, true for today and later", () => {
|
it("is false for yesterday, true for today and later", () => {
|
||||||
expect(isTodayOrFuture("2026-07-06T10:00:00Z")).toBe(false);
|
expect(isTodayOrFuture("2026-07-06T10:00:00Z", today())).toBe(false);
|
||||||
expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true);
|
expect(isTodayOrFuture("2026-07-07T20:00:00Z", today())).toBe(true);
|
||||||
expect(isTodayOrFuture("2026-08-01T00:00:00Z")).toBe(true);
|
expect(isTodayOrFuture("2026-08-01T00:00:00Z", today())).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("judges the day by Oslo wall clock, not UTC", () => {
|
it("judges the day by Oslo wall clock, not UTC", () => {
|
||||||
// 22:30Z on the 6th is 00:30 on the 7th ("today") in Oslo
|
// 22:30Z on the 6th is 00:30 on the 7th ("today") in Oslo
|
||||||
expect(isTodayOrFuture("2026-07-06T22:30:00Z")).toBe(true);
|
expect(isTodayOrFuture("2026-07-06T22:30:00Z", today())).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+21
-20
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
isToday,
|
isBefore,
|
||||||
isAfter,
|
|
||||||
parse,
|
parse,
|
||||||
compareAsc,
|
compareAsc,
|
||||||
addDays,
|
addDays,
|
||||||
@@ -8,7 +7,7 @@ import {
|
|||||||
startOfDay,
|
startOfDay,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { nb } from "date-fns/locale";
|
import { nb } from "date-fns/locale";
|
||||||
import { toZonedTime, format } from "date-fns-tz";
|
import { toZonedTime, format, formatInTimeZone } from "date-fns-tz";
|
||||||
import { unique } from "./common";
|
import { unique } from "./common";
|
||||||
|
|
||||||
const timeZone = "Europe/Oslo";
|
const timeZone = "Europe/Oslo";
|
||||||
@@ -20,19 +19,26 @@ export function toLocalTime(date: Date | string | number) {
|
|||||||
return toZonedTime(date, timeZone);
|
return toZonedTime(date, timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start of the current Oslo day, regardless of the server's TZ
|
// The Oslo calendar day ("yyyy-MM-dd") at the given instant, regardless of
|
||||||
export function startOfTodayInOslo() {
|
// the server's TZ. Rendering code must not call this without a cache scope:
|
||||||
return startOfDay(toLocalTime(new Date()));
|
// get the day from getToday() or useToday() instead
|
||||||
|
export function osloDay(now: Date = new Date()): string {
|
||||||
|
return formatInTimeZone(now, timeZone, "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start of an Oslo day, comparable with toLocalTime() values
|
||||||
|
export function startOfOsloDay(day: string): Date {
|
||||||
|
return parse(day, "yyyy-MM-dd", new Date(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date | string | number, formatStr: string) {
|
export function formatDate(date: Date | string | number, formatStr: string) {
|
||||||
return format(toLocalTime(date), formatStr, { timeZone, locale: nb });
|
return format(toLocalTime(date), formatStr, { timeZone, locale: nb });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatYearMonth(yearMonth: string) {
|
export function formatYearMonth(yearMonth: string, today: Date) {
|
||||||
// full name of month if year is current year, otherwise name of month + year
|
// full name of month if year is current year, otherwise name of month + year
|
||||||
const parsed = parse(yearMonth, "yyyy-MM", new Date());
|
const parsed = parse(yearMonth, "yyyy-MM", new Date(0));
|
||||||
if (parsed.getFullYear() === new Date().getFullYear()) {
|
if (parsed.getFullYear() === today.getFullYear()) {
|
||||||
return formatDate(parsed, "MMMM");
|
return formatDate(parsed, "MMMM");
|
||||||
}
|
}
|
||||||
return formatDate(parsed, "MMMM yyyy");
|
return formatDate(parsed, "MMMM yyyy");
|
||||||
@@ -40,6 +46,7 @@ export function formatYearMonth(yearMonth: string) {
|
|||||||
|
|
||||||
export function formatExtendedDateTime(
|
export function formatExtendedDateTime(
|
||||||
date: Date | string | number,
|
date: Date | string | number,
|
||||||
|
today: Date,
|
||||||
dateOnly: boolean = false,
|
dateOnly: boolean = false,
|
||||||
alwaysIncludeYear: boolean = false
|
alwaysIncludeYear: boolean = false
|
||||||
) {
|
) {
|
||||||
@@ -47,21 +54,15 @@ export function formatExtendedDateTime(
|
|||||||
// year included if not current year
|
// year included if not current year
|
||||||
const parsed = toLocalTime(date);
|
const parsed = toLocalTime(date);
|
||||||
const timePart = dateOnly ? "" : " 'kl.' HH:mm";
|
const timePart = dateOnly ? "" : " 'kl.' HH:mm";
|
||||||
const isCurrentYear = parsed.getFullYear() === new Date().getFullYear();
|
const isCurrentYear = parsed.getFullYear() === today.getFullYear();
|
||||||
const yearPart = (!isCurrentYear || alwaysIncludeYear) ? " yyyy" : "";
|
const yearPart = (!isCurrentYear || alwaysIncludeYear) ? " yyyy" : "";
|
||||||
const formatStr = `EEEE d. MMMM${yearPart}${timePart}`;
|
const formatStr = `EEEE d. MMMM${yearPart}${timePart}`;
|
||||||
const formatted = format(parsed, formatStr, { timeZone, locale: nb });
|
const formatted = format(parsed, formatStr, { timeZone, locale: nb });
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTodayOrFuture(
|
export function isTodayOrFuture(date: Date | string | number, today: Date) {
|
||||||
date: Date | string | number,
|
return !isBefore(toLocalTime(date), today);
|
||||||
timeZone = "UTC"
|
|
||||||
) {
|
|
||||||
const now = new Date();
|
|
||||||
const zonedNow = toZonedTime(now, timeZone);
|
|
||||||
const zonedDate = toLocalTime(date);
|
|
||||||
return isToday(zonedDate) || isAfter(zonedDate, zonedNow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareDates(a: Date | string, b: Date | string) {
|
export function compareDates(a: Date | string, b: Date | string) {
|
||||||
@@ -124,11 +125,11 @@ export function formatOccurrenceMonths(starts: string[]): string {
|
|||||||
|
|
||||||
return groups
|
return groups
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const first = parse(g[0], "yyyy-MM", new Date());
|
const first = parse(g[0], "yyyy-MM", new Date(0));
|
||||||
if (g.length === 1) {
|
if (g.length === 1) {
|
||||||
return formatDate(first, "MMMM yyyy");
|
return formatDate(first, "MMMM yyyy");
|
||||||
}
|
}
|
||||||
const last = parse(g[g.length - 1], "yyyy-MM", new Date());
|
const last = parse(g[g.length - 1], "yyyy-MM", new Date(0));
|
||||||
const firstFmt =
|
const firstFmt =
|
||||||
first.getFullYear() === last.getFullYear()
|
first.getFullYear() === last.getFullYear()
|
||||||
? formatDate(first, "MMMM")
|
? formatDate(first, "MMMM")
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
organizeEventsInCalendar,
|
organizeEventsInCalendar,
|
||||||
sortSingularEvents,
|
sortSingularEvents,
|
||||||
} from "./event.ts";
|
} from "./event.ts";
|
||||||
|
import { osloDay, startOfOsloDay } from "./date.ts";
|
||||||
|
|
||||||
|
// what getToday()/useToday() hand to the helpers
|
||||||
|
const today = () => startOfOsloDay(osloDay());
|
||||||
|
|
||||||
// "now" is tirsdag 2026-07-07 12:00 in Oslo
|
// "now" is tirsdag 2026-07-07 12:00 in Oslo
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -180,7 +184,7 @@ describe("getFutureOccurrences", () => {
|
|||||||
"2026-07-07T18:00:00Z",
|
"2026-07-07T18:00:00Z",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getFutureOccurrences(event).map((o) => o.start)).toEqual([
|
expect(getFutureOccurrences(event, today()).map((o) => o.start)).toEqual([
|
||||||
"2026-07-07T18:00:00Z",
|
"2026-07-07T18:00:00Z",
|
||||||
"2026-07-14T18:00:00Z",
|
"2026-07-14T18:00:00Z",
|
||||||
]);
|
]);
|
||||||
@@ -197,7 +201,7 @@ describe("getFutureOccurrences", () => {
|
|||||||
"2026-07-08T18:00:00Z",
|
"2026-07-08T18:00:00Z",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getFutureOccurrences(event).map((o) => o.start)).toEqual([
|
expect(getFutureOccurrences(event, today()).map((o) => o.start)).toEqual([
|
||||||
"2026-07-08T18:00:00Z",
|
"2026-07-08T18:00:00Z",
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
toLocalTime,
|
toLocalTime,
|
||||||
formatDate,
|
formatDate,
|
||||||
compareDates,
|
compareDates,
|
||||||
startOfTodayInOslo,
|
|
||||||
} from "./date";
|
} from "./date";
|
||||||
import { graphql, unmaskFragment } from "@/gql";
|
import { graphql, unmaskFragment } from "@/gql";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
type EventOrganizerFragment,
|
type EventOrganizerFragment,
|
||||||
type EventOverviewItemFragment,
|
type EventOverviewItemFragment,
|
||||||
} from "@/gql/graphql";
|
} from "@/gql/graphql";
|
||||||
import { PIG_NAMES, randomElement } from "@/lib/common";
|
import { PIG_NAMES, seededElement } from "@/lib/common";
|
||||||
|
|
||||||
export type EventOccurrence = EventFragment["occurrences"][number];
|
export type EventOccurrence = EventFragment["occurrences"][number];
|
||||||
export type EventListItemOccurrence = EventListItemFragment["occurrences"][number];
|
export type EventListItemOccurrence = EventListItemFragment["occurrences"][number];
|
||||||
@@ -353,9 +352,9 @@ export function organizeEventsByDate<E extends EventListable>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFutureOccurrences<E extends EventListable>(
|
export function getFutureOccurrences<E extends EventListable>(
|
||||||
event: E
|
event: E,
|
||||||
|
today: Date
|
||||||
): E["occurrences"][number][] {
|
): E["occurrences"][number][] {
|
||||||
const today = startOfTodayInOslo();
|
|
||||||
const occurrences = event?.occurrences ?? [];
|
const occurrences = event?.occurrences ?? [];
|
||||||
const futureOccurrences = occurrences.filter((occurrence) =>
|
const futureOccurrences = occurrences.filter((occurrence) =>
|
||||||
isAfter(toLocalTime(occurrence.start), today)
|
isAfter(toLocalTime(occurrence.start), today)
|
||||||
@@ -381,7 +380,7 @@ export function getEventPig(event: EventFragment): string | null {
|
|||||||
const categoryPigs = categories
|
const categoryPigs = categories
|
||||||
?.map((category) => category.pig)
|
?.map((category) => category.pig)
|
||||||
.filter((pig) => PIG_NAMES.includes(pig));
|
.filter((pig) => PIG_NAMES.includes(pig));
|
||||||
const chosenPig = randomElement(categoryPigs ?? []);
|
const chosenPig = seededElement(categoryPigs ?? [], String(event.id));
|
||||||
return typeof chosenPig === "string" ? chosenPig : null;
|
return typeof chosenPig === "string" ? chosenPig : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { CacheTag } from "@/lib/revalidation";
|
||||||
|
|
||||||
|
// The server purges its own cache (at startup, at Oslo midnight, and as a
|
||||||
|
// follow-up to webhooks) by calling /api/revalidate, because revalidateTag
|
||||||
|
// only works inside a request. The secret for those calls is made up per
|
||||||
|
// process and kept on globalThis: instrumentation.ts and the route handler
|
||||||
|
// are bundled separately
|
||||||
|
|
||||||
|
const KEY = Symbol.for("neuf.internalRevalidateSecret");
|
||||||
|
type Store = { [KEY]?: string };
|
||||||
|
|
||||||
|
export function internalRevalidateSecret(): string | undefined {
|
||||||
|
// an empty secret must never count as set
|
||||||
|
return (globalThis as Store)[KEY] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInternalRevalidateSecret(secret: string) {
|
||||||
|
(globalThis as Store)[KEY] = secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PURGE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
// The server is reached the way it listens (the standalone server binds to
|
||||||
|
// HOSTNAME); the wildcard addresses are reachable over loopback
|
||||||
|
function ownOrigin(): string {
|
||||||
|
const hostname = process.env.HOSTNAME;
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
if (!hostname || hostname === "0.0.0.0" || hostname === "::") {
|
||||||
|
return `http://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves to null on success, otherwise to what went wrong
|
||||||
|
export async function purgeOwnCache(tag: CacheTag): Promise<string | null> {
|
||||||
|
const secret = internalRevalidateSecret();
|
||||||
|
if (!secret) return "no internal secret";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ownOrigin()}/api/revalidate?tag=${tag}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-revalidate-secret": secret },
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(PURGE_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
return res.ok ? null : `HTTP ${res.status}`;
|
||||||
|
} catch (error) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// sever the server-only @/app/client import chain; the network functions are not under test
|
// sever the server-only @/app/client import chain; the network functions are not under test
|
||||||
vi.mock("@/app/client", () => ({ getClient: vi.fn() }));
|
vi.mock("@/app/client", () => ({ cmsQuery: vi.fn() }));
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OpeningHoursRangeBlockFragment,
|
OpeningHoursRangeBlockFragment,
|
||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
getTodaysOpeningHoursForFunction,
|
getTodaysOpeningHoursForFunction,
|
||||||
groupOpeningHours,
|
groupOpeningHours,
|
||||||
} from "./openinghours.ts";
|
} from "./openinghours.ts";
|
||||||
|
import { osloDay, startOfOsloDay } from "./date.ts";
|
||||||
|
|
||||||
|
// what getToday()/useToday() hand to the helpers
|
||||||
|
const today = () => startOfOsloDay(osloDay());
|
||||||
|
|
||||||
const range = (
|
const range = (
|
||||||
timeFrom: string | null,
|
timeFrom: string | null,
|
||||||
@@ -138,28 +142,28 @@ describe("getTodaysOpeningHoursForFunction", () => {
|
|||||||
|
|
||||||
it("returns today's hours", () => {
|
it("returns today's hours", () => {
|
||||||
vi.setSystemTime(new Date("2026-07-07T10:00:00Z")); // tirsdag
|
vi.setSystemTime(new Date("2026-07-07T10:00:00Z")); // tirsdag
|
||||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||||
"16:00—23:00"
|
"16:00—23:00"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns Stengt for a null day", () => {
|
it("returns Stengt for a null day", () => {
|
||||||
vi.setSystemTime(new Date("2026-07-11T10:00:00Z")); // lørdag
|
vi.setSystemTime(new Date("2026-07-11T10:00:00Z")); // lørdag
|
||||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||||
"Stengt"
|
"Stengt"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the custom text when set", () => {
|
it("returns the custom text when set", () => {
|
||||||
vi.setSystemTime(new Date("2026-07-12T10:00:00Z")); // søndag
|
vi.setSystemTime(new Date("2026-07-12T10:00:00Z")); // søndag
|
||||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||||
"Kun ved arrangement"
|
"Kun ved arrangement"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns ? for unknown functions", () => {
|
it("returns ? for unknown functions", () => {
|
||||||
vi.setSystemTime(new Date("2026-07-07T10:00:00Z"));
|
vi.setSystemTime(new Date("2026-07-07T10:00:00Z"));
|
||||||
expect(getTodaysOpeningHoursForFunction(openingHours, "kafé")).toBe("?");
|
expect(getTodaysOpeningHoursForFunction(openingHours, "kafé", today())).toBe("?");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the Oslo weekday when the server runs in UTC", () => {
|
it("uses the Oslo weekday when the server runs in UTC", () => {
|
||||||
@@ -168,12 +172,12 @@ describe("getTodaysOpeningHoursForFunction", () => {
|
|||||||
try {
|
try {
|
||||||
// 22:20 UTC on torsdag is 00:20 fredag in Oslo (CEST)
|
// 22:20 UTC on torsdag is 00:20 fredag in Oslo (CEST)
|
||||||
vi.setSystemTime(new Date("2026-07-09T22:20:00Z"));
|
vi.setSystemTime(new Date("2026-07-09T22:20:00Z"));
|
||||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||||
"15:00—01:00"
|
"15:00—01:00"
|
||||||
);
|
);
|
||||||
// 23:20 UTC on torsdag is 00:20 fredag in Oslo (CET)
|
// 23:20 UTC on torsdag is 00:20 fredag in Oslo (CET)
|
||||||
vi.setSystemTime(new Date("2026-01-08T23:20:00Z"));
|
vi.setSystemTime(new Date("2026-01-08T23:20:00Z"));
|
||||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||||
"15:00—01:00"
|
"15:00—01:00"
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+10
-13
@@ -1,8 +1,6 @@
|
|||||||
import { compareDesc, getISODay, isAfter, isSameDay, parseISO } from "date-fns";
|
import { compareDesc, getISODay, isAfter, isSameDay, parseISO } from "date-fns";
|
||||||
|
|
||||||
import { getClient } from "@/app/client";
|
import { cmsQuery } from "@/app/client";
|
||||||
import { startOfTodayInOslo } from "@/lib/date";
|
|
||||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
|
||||||
import { graphql, unmaskFragment } from "@/gql";
|
import { graphql, unmaskFragment } from "@/gql";
|
||||||
import type {
|
import type {
|
||||||
OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock,
|
OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock,
|
||||||
@@ -45,19 +43,17 @@ const openingHoursQuery = graphql(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
export async function fetchOpeningHoursSets() {
|
export async function fetchOpeningHoursSets() {
|
||||||
const { data, error } = await getClient().query(
|
// The footer shows these on every page; a failed query must not take the
|
||||||
openingHoursQuery,
|
// whole site down with it
|
||||||
{},
|
const data = await cmsQuery(openingHoursQuery, {}).catch((error) => {
|
||||||
// The footer renders today's hours on every page, so this TTL doubles as
|
console.error("Failed to fetch opening hours", error);
|
||||||
// the sitewide midnight refresh for futureEvents rollover
|
return undefined;
|
||||||
cachedUntilOsloMidnight()
|
});
|
||||||
);
|
|
||||||
const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[];
|
const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[];
|
||||||
return sets;
|
return sets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOpeningHours() {
|
export async function getOpeningHours(today: Date) {
|
||||||
const today = startOfTodayInOslo();
|
|
||||||
const sets = await fetchOpeningHoursSets();
|
const sets = await fetchOpeningHoursSets();
|
||||||
const validSets = sets
|
const validSets = sets
|
||||||
.filter((set) => {
|
.filter((set) => {
|
||||||
@@ -202,12 +198,13 @@ export function getPrettyOpeningHoursForFunction(
|
|||||||
export function getTodaysOpeningHoursForFunction(
|
export function getTodaysOpeningHoursForFunction(
|
||||||
openingHours: OpeningHoursSet,
|
openingHours: OpeningHoursSet,
|
||||||
name: string,
|
name: string,
|
||||||
|
today: Date,
|
||||||
): string {
|
): string {
|
||||||
const week: any = getOpeningHoursForFunction(openingHours, name);
|
const week: any = getOpeningHoursForFunction(openingHours, name);
|
||||||
if (!week) {
|
if (!week) {
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
const weekdayIndex = getISODay(startOfTodayInOslo()) - 1;
|
const weekdayIndex = getISODay(today) - 1;
|
||||||
const weekday = WEEKDAYS[weekdayIndex];
|
const weekday = WEEKDAYS[weekdayIndex];
|
||||||
const hours = week[weekday];
|
const hours = week[weekday];
|
||||||
if (hours?.timeFrom && hours?.timeTo) {
|
if (hours?.timeFrom && hours?.timeTo) {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import { secondsUntilOsloMidnight } from "./revalidation.ts";
|
||||||
cachedUntilOsloMidnight,
|
|
||||||
secondsUntilOsloMidnight,
|
|
||||||
} from "./revalidation.ts";
|
|
||||||
|
|
||||||
describe("secondsUntilOsloMidnight", () => {
|
describe("secondsUntilOsloMidnight", () => {
|
||||||
it("is 12h from midnight at 12:00 CEST on a normal day", () => {
|
it("is 12h from midnight at 12:00 CEST on a normal day", () => {
|
||||||
@@ -11,9 +8,14 @@ describe("secondsUntilOsloMidnight", () => {
|
|||||||
expect(secondsUntilOsloMidnight(now)).toBe(12 * 3600);
|
expect(secondsUntilOsloMidnight(now)).toBe(12 * 3600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps to 60s just before midnight", () => {
|
it("counts down to the second just before midnight", () => {
|
||||||
const now = new Date("2026-07-07T21:59:30Z");
|
const now = new Date("2026-07-07T21:59:30Z");
|
||||||
expect(secondsUntilOsloMidnight(now)).toBe(60);
|
expect(secondsUntilOsloMidnight(now)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a full day at midnight itself", () => {
|
||||||
|
const now = new Date("2026-07-07T22:00:00Z");
|
||||||
|
expect(secondsUntilOsloMidnight(now)).toBe(24 * 3600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles the 25h DST fall-back day (2026-10-25)", () => {
|
it("handles the 25h DST fall-back day (2026-10-25)", () => {
|
||||||
@@ -26,29 +28,3 @@ describe("secondsUntilOsloMidnight", () => {
|
|||||||
expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600);
|
expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cachedUntilOsloMidnight", () => {
|
|
||||||
// urql types fetchOptions as RequestInit | () => RequestInit; we always pass an object
|
|
||||||
function fetchOptionsAt(now: Date): RequestInit {
|
|
||||||
const { fetchOptions } = cachedUntilOsloMidnight(now);
|
|
||||||
return typeof fetchOptions === "function" ? fetchOptions() : fetchOptions ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("keys the fetch cache on the Oslo date, not the UTC date", () => {
|
|
||||||
// 00:30 CEST on Sep 5 is still Sep 4 in UTC
|
|
||||||
const { headers } = fetchOptionsAt(new Date("2026-09-04T22:30:00Z"));
|
|
||||||
expect(headers).toEqual({ "x-oslo-day": "2026-09-05" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("changes the cache key at Oslo midnight", () => {
|
|
||||||
const before = fetchOptionsAt(new Date("2026-09-04T21:59:59Z"));
|
|
||||||
const after = fetchOptionsAt(new Date("2026-09-04T22:00:00Z"));
|
|
||||||
expect(before.headers).toEqual({ "x-oslo-day": "2026-09-04" });
|
|
||||||
expect(after.headers).toEqual({ "x-oslo-day": "2026-09-05" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("revalidates at Oslo midnight and tags the entry", () => {
|
|
||||||
const { next } = fetchOptionsAt(new Date("2026-09-05T10:00:00Z"));
|
|
||||||
expect(next).toEqual({ revalidate: 12 * 3600, tags: ["cms"] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+24
-25
@@ -1,42 +1,41 @@
|
|||||||
import { addDays, startOfDay } from "date-fns";
|
import { addDays, startOfDay } from "date-fns";
|
||||||
import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz";
|
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||||
import type { OperationContext } from "@urql/core";
|
import type { OperationContext } from "@urql/core";
|
||||||
|
|
||||||
const timeZone = "Europe/Oslo";
|
const timeZone = "Europe/Oslo";
|
||||||
|
|
||||||
// Global cache tag: any CMS content change purges everything
|
// Everything cached is kept until one of these tags is purged through
|
||||||
|
// /api/revalidate. Time only plays a part as a safety net, see CMS_CACHE_LIFE
|
||||||
|
|
||||||
|
// On all cached CMS data. Purged by the CMS webhook on any content change
|
||||||
export const CMS_CACHE_TAG = "cms";
|
export const CMS_CACHE_TAG = "cms";
|
||||||
|
|
||||||
const MIN_REVALIDATE_SECONDS = 60;
|
// On whatever depends on what day it is. Purged just after Oslo midnight
|
||||||
|
// by instrumentation.ts, which also purges both tags when the server starts
|
||||||
|
export const TODAY_CACHE_TAG = "today";
|
||||||
|
|
||||||
|
export const CACHE_TAGS = [CMS_CACHE_TAG, TODAY_CACHE_TAG] as const;
|
||||||
|
export type CacheTag = (typeof CACHE_TAGS)[number];
|
||||||
|
|
||||||
|
// The lifetime of every cache scope. A daily background refresh heals a
|
||||||
|
// missed purge, and keeps the s-maxage that pages advertise to shared caches
|
||||||
|
// down to a day. (An object rather than a named profile in next.config.mjs:
|
||||||
|
// profile names only type-check after Next has generated its types)
|
||||||
|
export const CMS_CACHE_LIFE = {
|
||||||
|
stale: 5 * 60,
|
||||||
|
revalidate: 24 * 60 * 60,
|
||||||
|
expire: 365 * 24 * 60 * 60,
|
||||||
|
};
|
||||||
|
|
||||||
// Clamped so a request just before midnight never yields revalidate ~0
|
|
||||||
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
|
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
|
||||||
const osloNow = toZonedTime(now, timeZone);
|
const osloNow = toZonedTime(now, timeZone);
|
||||||
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
|
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
|
||||||
const instant = fromZonedTime(nextOsloMidnight, timeZone);
|
const instant = fromZonedTime(nextOsloMidnight, timeZone);
|
||||||
return Math.max(
|
return Math.ceil((instant.getTime() - now.getTime()) / 1000);
|
||||||
Math.ceil((instant.getTime() - now.getTime()) / 1000),
|
|
||||||
MIN_REVALIDATE_SECONDS
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-operation contexts replace (not merge with) the client-default
|
// For per-user (search) and token-scoped (preview) queries, which must
|
||||||
// fetchOptions, so both contexts below are self-contained
|
// never be stored
|
||||||
|
|
||||||
// For per-user (search) and token-scoped (preview) queries
|
|
||||||
export const uncached: Partial<OperationContext> = {
|
export const uncached: Partial<OperationContext> = {
|
||||||
fetchOptions: { cache: "no-store" },
|
fetchOptions: { cache: "no-store" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// For queries whose results depend on "today" (futureEvents, opening hours)
|
|
||||||
export function cachedUntilOsloMidnight(
|
|
||||||
now: Date = new Date()
|
|
||||||
): Partial<OperationContext> {
|
|
||||||
return {
|
|
||||||
fetchOptions: {
|
|
||||||
// Part of the fetch cache key, so yesterday's entry is never reused
|
|
||||||
headers: { "x-oslo-day": formatInTimeZone(now, timeZone, "yyyy-MM-dd") },
|
|
||||||
next: { revalidate: secondsUntilOsloMidnight(now), tags: [CMS_CACHE_TAG] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cmsQuery } from "@/app/client";
|
||||||
|
import { graphql } from "@/gql";
|
||||||
|
|
||||||
|
const latestPageSlugQuery = graphql(`
|
||||||
|
query latestPageSlug($contentType: String!) {
|
||||||
|
pages(contentType: $contentType, order: "-first_published_at", limit: 1) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Can't collide with a real page: Wagtail slugs never start with an underscore
|
||||||
|
const PLACEHOLDER_SLUG = "__ingen-sider__";
|
||||||
|
|
||||||
|
// For routes with too many pages to prerender them all. Cache Components
|
||||||
|
// needs at least one param set to validate the route at build time, so this
|
||||||
|
// prerenders the latest page; the rest are rendered and cached on first
|
||||||
|
// request. With no pages in the CMS yet, a placeholder that renders the 404
|
||||||
|
// page keeps the build working
|
||||||
|
export async function latestSlugParams(
|
||||||
|
contentType: string
|
||||||
|
): Promise<{ slug: string }[]> {
|
||||||
|
const data = await cmsQuery(latestPageSlugQuery, { contentType });
|
||||||
|
const slug = data?.pages?.[0]?.slug ?? PLACEHOLDER_SLUG;
|
||||||
|
return [{ slug }];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { todayCache } from "@/lib/cmsCache";
|
||||||
|
import { osloDay, startOfOsloDay } from "@/lib/date";
|
||||||
|
|
||||||
|
// The current Oslo day ("yyyy-MM-dd"), cached until the midnight purge. This
|
||||||
|
// is the only place rendering code gets "today" from, so prerendered pages
|
||||||
|
// stay static for the whole day and roll over together
|
||||||
|
export async function getOsloDay(): Promise<string> {
|
||||||
|
"use cache";
|
||||||
|
todayCache();
|
||||||
|
return osloDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start of the current Oslo day, for Server Components; Client Components
|
||||||
|
// use useToday()
|
||||||
|
export async function getToday(): Promise<Date> {
|
||||||
|
return startOfOsloDay(await getOsloDay());
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AllAssociationsQuery,
|
AllAssociationsQuery,
|
||||||
AllGenericSlugsQuery,
|
AllGenericSlugsQuery,
|
||||||
AllVenueSlugsQuery,
|
AllVenueSlugsQuery,
|
||||||
|
LatestPageSlugQuery,
|
||||||
AssociationBySlugQuery,
|
AssociationBySlugQuery,
|
||||||
AssociationFragment,
|
AssociationFragment,
|
||||||
AssociationIndexFragment,
|
AssociationIndexFragment,
|
||||||
@@ -53,6 +54,8 @@ import type {
|
|||||||
|
|
||||||
export type MockState = {
|
export type MockState = {
|
||||||
newsHeadlineVersion: "v1" | "v2";
|
newsHeadlineVersion: "v1" | "v2";
|
||||||
|
// how long the CMS takes to answer newsBySlug, to hold a page render open
|
||||||
|
newsDelayMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SLUGS = {
|
export const SLUGS = {
|
||||||
@@ -60,6 +63,8 @@ export const SLUGS = {
|
|||||||
association: "testforening",
|
association: "testforening",
|
||||||
venue: "storsalen",
|
venue: "storsalen",
|
||||||
news: "test-artikkel",
|
news: "test-artikkel",
|
||||||
|
// exists in the CMS but is not prerendered: rendered on first request
|
||||||
|
newsOnDemand: "eldre-artikkel",
|
||||||
event: "testkonsert",
|
event: "testkonsert",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -98,7 +103,7 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
|||||||
__typename: "EventCategory",
|
__typename: "EventCategory",
|
||||||
name: "Konsert",
|
name: "Konsert",
|
||||||
slug: "konsert",
|
slug: "konsert",
|
||||||
pig: "PIG_PINK",
|
pig: "music",
|
||||||
showInFilters: true,
|
showInFilters: true,
|
||||||
} satisfies EventCategoryFragment;
|
} satisfies EventCategoryFragment;
|
||||||
|
|
||||||
@@ -158,7 +163,8 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
|||||||
seoTitle: "Testkonsert",
|
seoTitle: "Testkonsert",
|
||||||
searchDescription: null,
|
searchDescription: null,
|
||||||
lead: "Bli med på testkonsert.",
|
lead: "Bli med på testkonsert.",
|
||||||
pig: "PIG_PINK",
|
// picks a pig from the event's categories, which must be prerenderable
|
||||||
|
pig: "automatic",
|
||||||
facebookUrl: null,
|
facebookUrl: null,
|
||||||
ticketUrl: null,
|
ticketUrl: null,
|
||||||
free: true,
|
free: true,
|
||||||
@@ -344,6 +350,15 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
|||||||
({
|
({
|
||||||
pages: [{ id: "30", slug: SLUGS.venue }],
|
pages: [{ id: "30", slug: SLUGS.venue }],
|
||||||
}) satisfies AllVenueSlugsQuery,
|
}) satisfies AllVenueSlugsQuery,
|
||||||
|
latestPageSlug: (vars) =>
|
||||||
|
({
|
||||||
|
pages:
|
||||||
|
vars.contentType === "news.NewsPage"
|
||||||
|
? [{ id: "20", slug: SLUGS.news }]
|
||||||
|
: vars.contentType === "events.EventPage"
|
||||||
|
? [{ id: "10", slug: SLUGS.event }]
|
||||||
|
: [],
|
||||||
|
}) satisfies LatestPageSlugQuery,
|
||||||
|
|
||||||
// --- build time: static pages ---
|
// --- build time: static pages ---
|
||||||
home: (_vars, state) =>
|
home: (_vars, state) =>
|
||||||
@@ -412,7 +427,10 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
|||||||
// --- runtime ---
|
// --- runtime ---
|
||||||
newsBySlug: (vars, state) =>
|
newsBySlug: (vars, state) =>
|
||||||
({
|
({
|
||||||
news: vars.slug === SLUGS.news ? mask(newsPage(state)) : null,
|
news:
|
||||||
|
vars.slug === SLUGS.news || vars.slug === SLUGS.newsOnDemand
|
||||||
|
? mask(newsPage(state))
|
||||||
|
: null,
|
||||||
}) satisfies NewsBySlugQuery,
|
}) satisfies NewsBySlugQuery,
|
||||||
eventBySlug: (vars) =>
|
eventBySlug: (vars) =>
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export default async function setup(project: TestProject) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mock.markBuildDone();
|
||||||
|
|
||||||
if (mock.graphqlRequests === 0) {
|
if (mock.graphqlRequests === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"next build made no GraphQL requests to the mock CMS — is WAGTAIL_BASE_URL being overridden?"
|
"next build made no GraphQL requests to the mock CMS — is WAGTAIL_BASE_URL being overridden?"
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export type MockCms = {
|
|||||||
url: string;
|
url: string;
|
||||||
state: MockState;
|
state: MockState;
|
||||||
graphqlRequests: number;
|
graphqlRequests: number;
|
||||||
|
// GraphQL requests per operation name, served at /__mock/requests so tests
|
||||||
|
// can tell which queries a re-render actually sent
|
||||||
|
requestsByOperation: Record<string, number>;
|
||||||
|
// call once `next build` is done; /__mock/requests then also reports the
|
||||||
|
// requests made since
|
||||||
|
markBuildDone(): void;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,11 +40,16 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
|||||||
export async function startMockCms(): Promise<MockCms> {
|
export async function startMockCms(): Promise<MockCms> {
|
||||||
const state: MockState = { newsHeadlineVersion: "v1" };
|
const state: MockState = { newsHeadlineVersion: "v1" };
|
||||||
let fixtures: Fixtures | null = null; // built once we know our own URL
|
let fixtures: Fixtures | null = null; // built once we know our own URL
|
||||||
|
let requestsAtBuild: Record<string, number> = {};
|
||||||
|
|
||||||
const mock: MockCms = {
|
const mock: MockCms = {
|
||||||
url: "",
|
url: "",
|
||||||
state,
|
state,
|
||||||
graphqlRequests: 0,
|
graphqlRequests: 0,
|
||||||
|
requestsByOperation: {},
|
||||||
|
markBuildDone: () => {
|
||||||
|
requestsAtBuild = { ...mock.requestsByOperation };
|
||||||
|
},
|
||||||
close: () =>
|
close: () =>
|
||||||
new Promise((resolve, reject) =>
|
new Promise((resolve, reject) =>
|
||||||
server.close((err) => (err ? reject(err) : resolve()))
|
server.close((err) => (err ? reject(err) : resolve()))
|
||||||
@@ -56,6 +67,20 @@ export async function startMockCms(): Promise<MockCms> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && req.url === "/__mock/requests") {
|
||||||
|
const sinceBuild = Object.fromEntries(
|
||||||
|
Object.entries(mock.requestsByOperation).map(([name, count]) => [
|
||||||
|
name,
|
||||||
|
count - (requestsAtBuild[name] ?? 0),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({ total: mock.requestsByOperation, sinceBuild })
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && req.url === "/__mock/state") {
|
if (req.method === "POST" && req.url === "/__mock/state") {
|
||||||
Object.assign(state, JSON.parse(await readBody(req)));
|
Object.assign(state, JSON.parse(await readBody(req)));
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
@@ -84,6 +109,10 @@ export async function startMockCms(): Promise<MockCms> {
|
|||||||
const name =
|
const name =
|
||||||
body.operationName ??
|
body.operationName ??
|
||||||
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
|
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
|
||||||
|
if (name) {
|
||||||
|
mock.requestsByOperation[name] =
|
||||||
|
(mock.requestsByOperation[name] ?? 0) + 1;
|
||||||
|
}
|
||||||
const resolve = name ? fixtures?.[name] : undefined;
|
const resolve = name ? fixtures?.[name] : undefined;
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
if (!resolve) {
|
if (!resolve) {
|
||||||
@@ -96,7 +125,14 @@ export async function startMockCms(): Promise<MockCms> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.end(JSON.stringify({ data: resolve(body.variables ?? {}, state) }));
|
// decide the answer first: a slow CMS returns what it had when asked
|
||||||
|
const answer = JSON.stringify({
|
||||||
|
data: resolve(body.variables ?? {}, state),
|
||||||
|
});
|
||||||
|
if (name === "newsBySlug" && state.newsDelayMs) {
|
||||||
|
await new Promise((r) => setTimeout(r, state.newsDelayMs));
|
||||||
|
}
|
||||||
|
res.end(answer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+142
-10
@@ -1,12 +1,12 @@
|
|||||||
// HTTP smoke tests against the built standalone app (see global-setup.ts).
|
// HTTP smoke tests against the built standalone app (see global-setup.ts).
|
||||||
// These cover the surfaces most likely to break on a Next.js upgrade:
|
// These cover the surfaces most likely to break on a Next.js upgrade:
|
||||||
// prerendered and request-rendered routes, 404s, the events API, the image
|
// prerendered and request-rendered routes, 404s, the events API, the image
|
||||||
// optimizer, the fetch-cache + tag revalidation round trip, and draft-mode
|
// optimizer, the cache + tag revalidation round trip, and draft-mode
|
||||||
// preview.
|
// preview.
|
||||||
|
|
||||||
import { beforeAll, describe, expect, inject, test } from "vitest";
|
import { afterAll, beforeAll, describe, expect, inject, test } from "vitest";
|
||||||
|
|
||||||
import { SLUGS } from "./fixtures";
|
import { type MockState, SLUGS } from "./fixtures";
|
||||||
|
|
||||||
const app = (path: string) => `${inject("appUrl")}${path}`;
|
const app = (path: string) => `${inject("appUrl")}${path}`;
|
||||||
const mock = (path: string) => `${inject("mockUrl")}${path}`;
|
const mock = (path: string) => `${inject("mockUrl")}${path}`;
|
||||||
@@ -16,14 +16,46 @@ async function getPage(path: string): Promise<{ status: number; html: string }>
|
|||||||
return { status: res.status, html: await res.text() };
|
return { status: res.status, html: await res.text() };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setNewsVersion(version: "v1" | "v2"): Promise<void> {
|
async function setMockState(state: Partial<MockState>): Promise<void> {
|
||||||
const res = await fetch(mock("/__mock/state"), {
|
const res = await fetch(mock("/__mock/state"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ newsHeadlineVersion: version }),
|
body: JSON.stringify(state),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setNewsVersion = (version: "v1" | "v2") =>
|
||||||
|
setMockState({ newsHeadlineVersion: version });
|
||||||
|
|
||||||
|
type CmsRequests = Record<string, number>;
|
||||||
|
|
||||||
|
// GraphQL requests the mock CMS has received, per operation name
|
||||||
|
async function cmsRequests(): Promise<{
|
||||||
|
total: CmsRequests;
|
||||||
|
sinceBuild: CmsRequests;
|
||||||
|
}> {
|
||||||
|
const res = await fetch(mock("/__mock/requests"));
|
||||||
|
return res.json() as Promise<{ total: CmsRequests; sinceBuild: CmsRequests }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing expires by time: the server purges its own cache when it starts
|
||||||
|
// and at Oslo midnight (instrumentation.ts).
|
||||||
|
// Must stay the first test: any later purge would also make this pass
|
||||||
|
describe("startup purge", () => {
|
||||||
|
test("pages from the build are rendered again", async () => {
|
||||||
|
// the purge lands just after the server starts listening
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
let sinceBuild: CmsRequests = {};
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await getPage("/");
|
||||||
|
({ sinceBuild } = await cmsRequests());
|
||||||
|
if ((sinceBuild.home ?? 0) > 0) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
expect(sinceBuild.home ?? 0).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("pages", () => {
|
describe("pages", () => {
|
||||||
test.each([
|
test.each([
|
||||||
["/", "Testkonsert"],
|
["/", "Testkonsert"],
|
||||||
@@ -32,7 +64,7 @@ describe("pages", () => {
|
|||||||
[`/${SLUGS.genericUrl}`, "Praktisk info"],
|
[`/${SLUGS.genericUrl}`, "Praktisk info"],
|
||||||
[`/foreninger/${SLUGS.association}`, "Testforeningen"],
|
[`/foreninger/${SLUGS.association}`, "Testforeningen"],
|
||||||
[`/lokaler/${SLUGS.venue}`, "Storsalen"],
|
[`/lokaler/${SLUGS.venue}`, "Storsalen"],
|
||||||
// not prerendered (empty generateStaticParams) — rendered on first request
|
// only the latest page of each is prerendered; these are the latest
|
||||||
[`/aktuelt/${SLUGS.news}`, "Testartikkel"],
|
[`/aktuelt/${SLUGS.news}`, "Testartikkel"],
|
||||||
[`/arrangementer/${SLUGS.event}`, "Testkonsert"],
|
[`/arrangementer/${SLUGS.event}`, "Testkonsert"],
|
||||||
])("%s renders and contains %s", async (path, needle) => {
|
])("%s renders and contains %s", async (path, needle) => {
|
||||||
@@ -51,6 +83,23 @@ describe("pages", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("event list", () => {
|
||||||
|
// The list is rendered per request inside the prerendered page, so that
|
||||||
|
// the URL's filters are applied on the server (no layout shift)
|
||||||
|
const activeButton = (html: string) =>
|
||||||
|
/data-active="true"[^>]*><span>Vis (liste|kalender)<\/span>/.exec(html)?.[1];
|
||||||
|
|
||||||
|
test("is server-rendered in list mode by default", async () => {
|
||||||
|
const { html } = await getPage("/arrangementer");
|
||||||
|
expect(activeButton(html)).toBe("liste");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is server-rendered with the mode from the URL", async () => {
|
||||||
|
const { html } = await getPage("/arrangementer?mode=calendar");
|
||||||
|
expect(activeButton(html)).toBe("kalender");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("events api", () => {
|
describe("events api", () => {
|
||||||
test("rejects a missing view parameter", async () => {
|
test("rejects a missing view parameter", async () => {
|
||||||
const res = await fetch(app("/api/v1/events"));
|
const res = await fetch(app("/api/v1/events"));
|
||||||
@@ -84,15 +133,18 @@ describe("image optimizer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetch cache and tag revalidation", () => {
|
// Runs for a page prerendered at build time and for one rendered on its
|
||||||
const path = `/aktuelt/${SLUGS.news}`;
|
// first request, which is how most news and event pages are served
|
||||||
|
describe.each([
|
||||||
|
["prerendered", `/aktuelt/${SLUGS.news}`],
|
||||||
|
["rendered on demand", `/aktuelt/${SLUGS.newsOnDemand}`],
|
||||||
|
])("cache and tag revalidation (%s page)", (_kind, path) => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await setNewsVersion("v1");
|
await setNewsVersion("v1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("serves cached content until the webhook fires, then updates", async () => {
|
test("serves cached content until the webhook fires, then updates", async () => {
|
||||||
// prime the cache (the route is request-rendered, then cached)
|
// prerendered at build time or rendered now; either way the CMS has v1
|
||||||
const first = await getPage(path);
|
const first = await getPage(path);
|
||||||
expect(first.status).toBe(200);
|
expect(first.status).toBe(200);
|
||||||
expect(first.html).toContain("Testartikkel (v1)");
|
expect(first.html).toContain("Testartikkel (v1)");
|
||||||
@@ -136,6 +188,86 @@ describe("fetch cache and tag revalidation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Next stamps a cached page when its render finishes, so a page that was
|
||||||
|
// being rendered while a purge came in looks newer than the purge. The
|
||||||
|
// server sends itself a follow-up purge a few seconds after each webhook
|
||||||
|
describe("purge during a render", () => {
|
||||||
|
const path = `/aktuelt/${SLUGS.newsOnDemand}`;
|
||||||
|
const webhook = () =>
|
||||||
|
fetch(app("/api/revalidate"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-revalidate-secret": "smoke-secret" },
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await setMockState({ newsDelayMs: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two edits in quick succession end up showing the second", async () => {
|
||||||
|
// first edit: published and purged, and the CMS is slow to answer
|
||||||
|
await setMockState({ newsHeadlineVersion: "v1", newsDelayMs: 1_500 });
|
||||||
|
expect((await webhook()).status).toBe(200);
|
||||||
|
const visitor = getPage(path);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
|
||||||
|
// second edit lands while the visitor's page is still rendering
|
||||||
|
await setMockState({ newsHeadlineVersion: "v2", newsDelayMs: 0 });
|
||||||
|
expect((await webhook()).status).toBe(200);
|
||||||
|
|
||||||
|
// that visitor gets what the CMS had when it was asked
|
||||||
|
expect((await visitor).html).toContain("Testartikkel (v1)");
|
||||||
|
|
||||||
|
// everyone after must get the second edit once the follow-up has run
|
||||||
|
const deadline = Date.now() + 12_000;
|
||||||
|
let html = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
({ html } = await getPage(path));
|
||||||
|
if (html.includes("Testartikkel (v2)")) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
expect(html).toContain("Testartikkel (v2)");
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("midnight purge", () => {
|
||||||
|
async function cacheStatus(path: string): Promise<string | null> {
|
||||||
|
const res = await fetch(app(path));
|
||||||
|
await res.text();
|
||||||
|
return res.headers.get("x-nextjs-cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the midnight purge re-renders pages but only refetches date-dependent data", async () => {
|
||||||
|
const purgeToday = () =>
|
||||||
|
fetch(app("/api/revalidate?tag=today"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-revalidate-secret": "smoke-secret" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// settle: everything rendered and cached by this server process
|
||||||
|
expect((await purgeToday()).status).toBe(200);
|
||||||
|
await getPage("/");
|
||||||
|
await getPage("/kontakt");
|
||||||
|
expect(await cacheStatus("/")).toBe("HIT");
|
||||||
|
expect(await cacheStatus("/kontakt")).toBe("HIT");
|
||||||
|
const before = (await cmsRequests()).total;
|
||||||
|
|
||||||
|
expect((await purgeToday()).status).toBe(200);
|
||||||
|
|
||||||
|
// every page shows today's date somewhere, so every page renders again
|
||||||
|
expect(await cacheStatus("/")).not.toBe("HIT");
|
||||||
|
expect(await cacheStatus("/kontakt")).not.toBe("HIT");
|
||||||
|
expect(await cacheStatus("/")).toBe("HIT");
|
||||||
|
expect(await cacheStatus("/kontakt")).toBe("HIT");
|
||||||
|
|
||||||
|
const after = (await cmsRequests()).total;
|
||||||
|
// the home page asks the CMS for future events again...
|
||||||
|
expect(after.home).toBe(before.home + 1);
|
||||||
|
// ...while content that only changes on edit comes from the cache
|
||||||
|
expect(after.contacts).toBe(before.contacts);
|
||||||
|
expect(after.openingHoursSets).toBe(before.openingHoursSets);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("preview", () => {
|
describe("preview", () => {
|
||||||
test("draft mode round trip renders the preview page", async () => {
|
test("draft mode round trip renders the preview page", async () => {
|
||||||
const enable = await fetch(
|
const enable = await fetch(
|
||||||
|
|||||||
Reference in new issue
Block a user