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
+1274
-305
No files matched your search
@@ -22,6 +22,13 @@ function remotePatterns() {
|
||||
|
||||
const nextConfig = {
|
||||
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: {
|
||||
remotePatterns: remotePatterns(),
|
||||
formats: ["image/avif", "image/webp"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
} from "@/components/general/GenericPageView";
|
||||
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 {
|
||||
// for the page /foo/bar we need to look for `/home/foo/bar/`
|
||||
return `/home/${url.join("/")}/`;
|
||||
@@ -22,10 +29,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const { data, error } = await getClient().query(allGenericSlugsQuery, {});
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQuery(allGenericSlugsQuery, {});
|
||||
if (!data?.pages) {
|
||||
throw new Error("Failed to generate static params for generic subpages");
|
||||
}
|
||||
|
||||
@@ -5,10 +5,17 @@ import {
|
||||
loadNewsPageProps,
|
||||
} from "@/components/news/NewsPageView";
|
||||
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() {
|
||||
// Prerender nothing at build time; render and cache on first request
|
||||
return [];
|
||||
return latestSlugParams("news.NewsPage");
|
||||
}
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
NewsIndexView,
|
||||
loadNewsIndexProps,
|
||||
@@ -8,12 +8,14 @@ import { NewsIndexFragment } from "@/gql/graphql";
|
||||
import { newsIndexMetadataQuery } from "@/lib/news";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
): Promise<Metadata | null> {
|
||||
const { data, error } = await getClient().query(newsIndexMetadataQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(newsIndexMetadataQuery, {});
|
||||
if (!data?.index) return null;
|
||||
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";
|
||||
|
||||
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
|
||||
|
||||
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";
|
||||
|
||||
const request = (headers: Record<string, string> = {}) =>
|
||||
new Request("http://localhost/api/revalidate", {
|
||||
const request = (headers: Record<string, string> = {}, query = "") =>
|
||||
new NextRequest(`http://localhost/api/revalidate${query}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
}) as unknown as NextRequest;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
setInternalRevalidateSecret("");
|
||||
});
|
||||
|
||||
describe("POST /api/revalidate", () => {
|
||||
@@ -47,4 +49,119 @@ describe("POST /api/revalidate", () => {
|
||||
expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG });
|
||||
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 { 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 {
|
||||
const a = Buffer.from(provided);
|
||||
@@ -10,7 +14,33 @@ function secretMatches(provided: string, expected: string): boolean {
|
||||
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) {
|
||||
const provided = req.headers.get("x-revalidate-secret") ?? "";
|
||||
// the server's own startup and midnight purges (instrumentation.ts)
|
||||
const internal = internalRevalidateSecret();
|
||||
const isInternal = internal !== undefined && secretMatches(provided, internal);
|
||||
|
||||
if (!isInternal) {
|
||||
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
@@ -18,11 +48,21 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
const provided = req.headers.get("x-revalidate-secret") ?? "";
|
||||
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
|
||||
revalidateTag(CMS_CACHE_TAG, { expire: 0 });
|
||||
return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG });
|
||||
revalidateTag(tag, { expire: 0 });
|
||||
// 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";
|
||||
|
||||
const queryMock = vi.fn();
|
||||
// mirrors cmsQueryForToday: returns the data, throws on a GraphQL error
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
||||
import { cmsQueryForToday } from "@/app/client";
|
||||
import { getToday } from "@/lib/today";
|
||||
import {
|
||||
eventsOverviewQuery,
|
||||
getSingularEvents,
|
||||
@@ -46,14 +46,7 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await getClient().query(
|
||||
eventsOverviewQuery,
|
||||
{},
|
||||
cachedUntilOsloMidnight()
|
||||
);
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQueryForToday(eventsOverviewQuery, {});
|
||||
if (
|
||||
!data?.index ||
|
||||
!data?.events?.futureEvents ||
|
||||
@@ -64,6 +57,7 @@ export async function GET(req: NextRequest) {
|
||||
throw new Error("Failed to fetch events");
|
||||
}
|
||||
|
||||
const today = await getToday();
|
||||
const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[];
|
||||
const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
|
||||
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
|
||||
@@ -71,7 +65,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"];
|
||||
const compactEvents = futureEvents.map((event) => {
|
||||
const futureOccurrences = getFutureOccurrences(event);
|
||||
const futureOccurrences = getFutureOccurrences(event, today);
|
||||
const nextOccurrence = futureOccurrences.length
|
||||
? futureOccurrences[0]
|
||||
: null;
|
||||
|
||||
@@ -5,10 +5,17 @@ import {
|
||||
loadEventPageProps,
|
||||
} from "@/components/events/EventPageView";
|
||||
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() {
|
||||
// Prerender nothing at build time; render and cache on first request
|
||||
return [];
|
||||
return latestSlugParams("events.EventPage");
|
||||
}
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
EventIndexView,
|
||||
loadEventIndexProps,
|
||||
@@ -8,22 +8,20 @@ import { EventIndexFragment } from "@/gql/graphql";
|
||||
import { eventIndexMetadataQuery } from "@/lib/event";
|
||||
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(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
): Promise<Metadata | null> {
|
||||
const { data, error } = await getClient().query(eventIndexMetadataQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(eventIndexMetadataQuery, {});
|
||||
if (!data?.index) return null;
|
||||
return getSeoMetadata(data.index as EventIndexFragment, parent);
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
await searchParams;
|
||||
export default async function Page() {
|
||||
const props = await loadEventIndexProps();
|
||||
return <EventIndexView {...props} />;
|
||||
}
|
||||
+52
-5
@@ -1,9 +1,16 @@
|
||||
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 { CMS_CACHE_TAG } from "@/lib/revalidation";
|
||||
import { cmsCache, cmsCacheForToday } from "@/lib/cmsCache";
|
||||
|
||||
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
|
||||
if (!wagtailBaseUrl) {
|
||||
@@ -15,10 +22,50 @@ const makeClient = () => {
|
||||
return createClient({
|
||||
url: graphqlEndpoint,
|
||||
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);
|
||||
|
||||
// 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 { notFound } from "next/navigation";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
AssociationPageView,
|
||||
loadAssociationPageProps,
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
import { graphql } from "@/gql";
|
||||
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 }>;
|
||||
|
||||
export async function generateMetadata(
|
||||
@@ -29,11 +36,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const { data, error } = await getClient().query(allAssociationSlugsQuery, {});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQuery(allAssociationSlugsQuery, {});
|
||||
if (!data?.pages) {
|
||||
throw new Error(
|
||||
"Failed to generate static params for subpages of /foreninger"
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/associations/AssociationIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/contact/ContactIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 { Metadata } from "next";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
@@ -26,11 +28,12 @@ export const metadata: Metadata = {
|
||||
...baseUrlMetadata,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const day = await getOsloDay();
|
||||
return (
|
||||
<html lang="no">
|
||||
<head>
|
||||
@@ -61,9 +64,11 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body>
|
||||
<NuqsAdapter>
|
||||
<Header />
|
||||
<TodayProvider day={day}>
|
||||
<SeasonalHeader />
|
||||
{children}
|
||||
<Footer />
|
||||
</TodayProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
VenuePageView,
|
||||
loadVenuePageProps,
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
import { graphql } from "@/gql";
|
||||
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 }>;
|
||||
|
||||
const EXCLUDED_SLUGS = ["hele-huset"];
|
||||
@@ -31,11 +38,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const { data, error } = await getClient().query(allVenueSlugsQuery, {});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQuery(allVenueSlugsQuery, {});
|
||||
if (!data?.pages) {
|
||||
throw new Error(
|
||||
"Failed to generate static params for subpages of /lokaler"
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/venues/VenueIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
loadHomePageProps,
|
||||
} from "@/components/home/HomePageView";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export default async function Home() {
|
||||
const props = await loadHomePageProps();
|
||||
return <HomePageView {...props} />;
|
||||
|
||||
@@ -74,8 +74,10 @@ import {
|
||||
VenueRentalIndexFragment,
|
||||
} from "@/gql/graphql";
|
||||
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(`
|
||||
query previewPage($token: String!) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { getClient } from "@/app/client";
|
||||
import { uncached } from "@/lib/revalidation";
|
||||
import {
|
||||
@@ -9,6 +10,10 @@ import { graphql } from "@/gql";
|
||||
|
||||
// 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({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -17,6 +22,8 @@ export default async function Page({
|
||||
}>;
|
||||
}) {
|
||||
const { q: query } = (await searchParams) ?? {};
|
||||
// search always runs at request time
|
||||
await connection();
|
||||
let results: SearchResult[] = [];
|
||||
let totalCount = 0;
|
||||
const RESULT_LIMIT = 500;
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/sponsor/SponsorsPageView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/studio/StudioPageView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/venues/VenueRentalIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { AssociationFragment, AssociationIndexFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { AssociationList } from "@/components/associations/AssociationList";
|
||||
import { PageHeader } from "@/components/general/PageHeader";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
@@ -67,8 +67,7 @@ export type AssociationIndexViewProps = {
|
||||
export async function loadAssociationIndexProps(overrides?: {
|
||||
indexOverride?: AssociationIndexFragment;
|
||||
}): Promise<AssociationIndexViewProps> {
|
||||
const { data, error } = await getClient().query(allAssociationsQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(allAssociationsQuery, {});
|
||||
const index =
|
||||
overrides?.indexOverride ?? (data?.index as AssociationIndexFragment | undefined);
|
||||
if (!index) throw new Error("Failed to load /foreninger");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { AssociationFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { AssociationHeader } from "@/components/associations/AssociationHeader";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
|
||||
@@ -27,10 +27,9 @@ export async function loadAssociationPageProps(args: {
|
||||
return { association: args.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,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
const association = data?.association as AssociationFragment | undefined;
|
||||
if (!association) return null;
|
||||
return { association };
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getPrettyOpeningHoursForFunction,
|
||||
PrettyOpeningHours,
|
||||
} from "@/lib/openinghours";
|
||||
import { getToday } from "@/lib/today";
|
||||
import styles from "./openingHoursSectionBlock.module.scss";
|
||||
import { Fragment } from "react";
|
||||
|
||||
@@ -35,7 +36,7 @@ function OpeningHoursSubsection({
|
||||
}
|
||||
|
||||
export async function OpeningHoursSectionBlock() {
|
||||
const allOpeningHours = await getOpeningHours();
|
||||
const allOpeningHours = await getOpeningHours(await getToday());
|
||||
const subsections = [
|
||||
["glassbaren", "Glassbaren"],
|
||||
["bokcafeen", "Bokcaféen"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { ContactIndexFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { GeneralContactBlock } from "@/components/blocks/GeneralContactBlock";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
import { PageHeader } from "@/components/general/PageHeader";
|
||||
@@ -36,8 +36,7 @@ export async function loadContactIndexProps(overrides?: {
|
||||
if (overrides?.indexOverride) {
|
||||
return { index: overrides.indexOverride };
|
||||
}
|
||||
const { data, error } = await getClient().query(contactQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(contactQuery, {});
|
||||
const index = data?.index as ContactIndexFragment | undefined;
|
||||
if (!index) throw new Error("Failed to load /kontakt");
|
||||
return { index };
|
||||
|
||||
@@ -8,22 +8,24 @@ import {
|
||||
compareDates,
|
||||
} from "@/lib/date";
|
||||
import { EventFragment, EventOccurrence } from "@/lib/event";
|
||||
import { useToday } from "@/components/general/Today";
|
||||
import styles from "./dateList.module.scss";
|
||||
import Link from "next/link";
|
||||
|
||||
const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
|
||||
const today = useToday();
|
||||
const timeFormat = "'kl.' HH:mm";
|
||||
const wholeCastle = occurrence.venue?.slug === "hele-huset";
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`${styles.date} ${
|
||||
isTodayOrFuture(occurrence.start) ? styles.future : styles.past
|
||||
isTodayOrFuture(occurrence.start, today) ? styles.future : styles.past
|
||||
}`}
|
||||
>
|
||||
<div className={styles.time}>
|
||||
<span className={styles.day}>
|
||||
{formatExtendedDateTime(occurrence.start, true)}
|
||||
{formatExtendedDateTime(occurrence.start, today, true)}
|
||||
</span>{" "}
|
||||
<span className={styles.hour}>
|
||||
{formatDate(occurrence.start, timeFormat)}
|
||||
@@ -49,6 +51,7 @@ const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
|
||||
};
|
||||
|
||||
export const DateList = ({ event }: { event: EventFragment }) => {
|
||||
const today = useToday();
|
||||
const [showAllDates, setShowAllDates] = useState(false);
|
||||
function toggleDates() {
|
||||
setShowAllDates(!showAllDates);
|
||||
@@ -57,7 +60,7 @@ export const DateList = ({ event }: { event: EventFragment }) => {
|
||||
const oneOff = event.occurrences.length === 1;
|
||||
const showToggle = event.occurrences.length > 4;
|
||||
const futureOccurrences = event.occurrences
|
||||
.filter((occurrence) => isTodayOrFuture(occurrence.start))
|
||||
.filter((occurrence) => isTodayOrFuture(occurrence.start, today))
|
||||
.sort((a, b) => compareDates(a.start, b.start));
|
||||
|
||||
let visibleOccurrences = [];
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getFilterableVenues,
|
||||
} from "@/lib/eventFilter";
|
||||
import { isTodayOrFuture } from "@/lib/date";
|
||||
import { useToday } from "@/components/general/Today";
|
||||
import styles from "./eventContainer.module.scss";
|
||||
import { formatDate, formatYearMonth } from "@/lib/date";
|
||||
import {
|
||||
@@ -190,7 +191,7 @@ const CalendarDay = ({
|
||||
<div
|
||||
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>
|
||||
{events.map((event) => (
|
||||
<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
|
||||
const yearMonth = new Date(yearMonthString);
|
||||
const isCurrentYear = yearMonth.getFullYear() === new Date().getFullYear();
|
||||
const isCurrentYear = yearMonth.getFullYear() === today.getFullYear();
|
||||
if (!isCurrentYear) {
|
||||
return null;
|
||||
}
|
||||
@@ -245,8 +246,9 @@ function srOnlyYear(yearMonthString: string) {
|
||||
}
|
||||
|
||||
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
||||
const today = useToday();
|
||||
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 yearMonths = Object.keys(eventsByDate);
|
||||
@@ -283,8 +285,8 @@ const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
||||
data-collapsed={!visibleYearMonths.includes(yearMonth)}
|
||||
>
|
||||
<h2 onClick={() => toggleYearMonth(yearMonth)}>
|
||||
{formatYearMonth(yearMonth)}
|
||||
{srOnlyYear(yearMonth)}{" "}
|
||||
{formatYearMonth(yearMonth, today)}
|
||||
{srOnlyYear(yearMonth, today)}{" "}
|
||||
<span className={styles.eventCounter}>({eventCount})</span>
|
||||
</h2>
|
||||
{eventCount === 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { connection } from "next/server";
|
||||
import { VenueFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
||||
import { cmsQueryForToday } from "@/app/client";
|
||||
import { EventContainer } from "@/components/events/EventContainer";
|
||||
import { PageHeader } from "@/components/general/PageHeader";
|
||||
import {
|
||||
@@ -18,12 +19,7 @@ export type EventIndexViewProps = {
|
||||
};
|
||||
|
||||
export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
|
||||
const { data, error } = await getClient().query(
|
||||
eventsOverviewQuery,
|
||||
{},
|
||||
cachedUntilOsloMidnight()
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQueryForToday(eventsOverviewQuery, {});
|
||||
if (
|
||||
!data?.index ||
|
||||
!data?.events?.futureEvents ||
|
||||
@@ -41,21 +37,22 @@ export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
|
||||
};
|
||||
}
|
||||
|
||||
export function EventIndexView({
|
||||
events,
|
||||
eventCategories,
|
||||
eventOrganizers,
|
||||
venues,
|
||||
}: EventIndexViewProps) {
|
||||
// Waiting for the request makes the list render on the server per request,
|
||||
// with the visitor's filters from the URL already applied. Prerendered, it
|
||||
// would only appear after hydration and shift when the URL has filters
|
||||
async function RequestTimeEventContainer(props: EventIndexViewProps) {
|
||||
await connection();
|
||||
return <EventContainer {...props} />;
|
||||
}
|
||||
|
||||
export function EventIndexView(props: EventIndexViewProps) {
|
||||
return (
|
||||
<main className="site-main" id="main">
|
||||
<PageHeader heading="Dette skjer på Chateau Neuf" align="left" />
|
||||
<EventContainer
|
||||
events={events}
|
||||
eventCategories={eventCategories}
|
||||
eventOrganizers={eventOrganizers}
|
||||
venues={venues}
|
||||
/>
|
||||
{/* the fallback reserves the list's height to keep the footer in place */}
|
||||
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>
|
||||
<RequestTimeEventContainer {...props} />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import styles from "./eventItem.module.scss";
|
||||
import Link from "next/link";
|
||||
import { Image } from "@/components/general/Image";
|
||||
import { useToday } from "@/components/general/Today";
|
||||
import {
|
||||
SingularEvent,
|
||||
EventFragment,
|
||||
@@ -37,7 +38,8 @@ export const EventItem = ({
|
||||
imageLoading?: "eager" | "lazy";
|
||||
imageFetchPriority?: "high" | "low" | "auto";
|
||||
}) => {
|
||||
const futureOccurrences = getFutureOccurrences(event);
|
||||
const today = useToday();
|
||||
const futureOccurrences = getFutureOccurrences(event, today);
|
||||
const groupedOccurrences = groupConsecutiveDates(
|
||||
futureOccurrences.map((occurrence) => occurrence.start)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { EventFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { EventDetails } from "@/components/events/EventDetails";
|
||||
import { EventHeader } from "@/components/events/EventHeader";
|
||||
import { BgPig } from "@/components/general/BgPig";
|
||||
@@ -28,10 +28,9 @@ export async function loadEventPageProps(args: {
|
||||
return { event: args.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,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
const event = data?.event as EventFragment | undefined;
|
||||
if (!event) return null;
|
||||
return { event };
|
||||
|
||||
@@ -9,11 +9,13 @@ import { EventItem } from "./EventItem";
|
||||
import styles from "./upcomingEvents.module.scss";
|
||||
import { SectionHeader } from "../general/SectionHeader";
|
||||
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(
|
||||
getSingularEvents(events).filter((event) =>
|
||||
isTodayOrFuture(event.occurrence.start)
|
||||
isTodayOrFuture(event.occurrence.start, today)
|
||||
)
|
||||
);
|
||||
const sliced = upcomingSingularEvents.slice(0, 10);
|
||||
@@ -30,7 +32,7 @@ export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] })
|
||||
<div key={day}>
|
||||
{/*<h3>
|
||||
{formatDate(
|
||||
parse(day, "yyyy-MM-dd", new Date()),
|
||||
parse(day, "yyyy-MM-dd", new Date(0)),
|
||||
"eeee dd.MM."
|
||||
)}
|
||||
</h3>*/}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { GenericFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { PageHeader } from "@/components/general/PageHeader";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
import { BgPig } from "@/components/general/BgPig";
|
||||
@@ -41,10 +41,9 @@ export async function loadGenericPageProps(args: {
|
||||
return { page: args.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,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
const page = data?.page as GenericFragment | undefined;
|
||||
if (!page) return null;
|
||||
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 { graphql } from "@/gql";
|
||||
import { HomeFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
||||
import { cmsQueryForToday } from "@/app/client";
|
||||
import { EventListItemFragment } from "@/lib/event";
|
||||
import { NewsListItemFragment } from "@/lib/news";
|
||||
import { FeaturedEvents } from "@/components/events/FeaturedEvents";
|
||||
@@ -57,12 +56,7 @@ export type HomePageViewProps = {
|
||||
export async function loadHomePageProps(overrides?: {
|
||||
homeOverride?: HomeFragment;
|
||||
}): Promise<HomePageViewProps> {
|
||||
const { data, error } = await getClient().query(
|
||||
homeQuery,
|
||||
{},
|
||||
cachedUntilOsloMidnight()
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQueryForToday(homeQuery, {});
|
||||
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
|
||||
if (!home) throw new Error("Failed to load /");
|
||||
const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
getOpeningHours,
|
||||
getTodaysOpeningHoursForFunction,
|
||||
} from "@/lib/openinghours";
|
||||
import { getToday } from "@/lib/today";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "../general/Icon";
|
||||
// import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
|
||||
@@ -9,7 +10,8 @@ import styles from "./footer.module.scss";
|
||||
import { PigPattern } from "./PigPattern";
|
||||
|
||||
async function OpeningHoursTable() {
|
||||
const allOpeningHours = await getOpeningHours();
|
||||
const today = await getToday();
|
||||
const allOpeningHours = await getOpeningHours(today);
|
||||
const subsections = [
|
||||
["glassbaren", "Glassbaren"],
|
||||
["bokcafeen", "Bokcaféen"],
|
||||
@@ -21,7 +23,11 @@ async function OpeningHoursTable() {
|
||||
<tbody>
|
||||
{subsections.map((subsection) => {
|
||||
const [slug, title] = subsection;
|
||||
const hours = getTodaysOpeningHoursForFunction(allOpeningHours, slug);
|
||||
const hours = getTodaysOpeningHoursForFunction(
|
||||
allOpeningHours,
|
||||
slug,
|
||||
today,
|
||||
);
|
||||
return (
|
||||
<tr key={slug}>
|
||||
<td>{title}</td>
|
||||
|
||||
@@ -8,7 +8,13 @@ import { Icon } from "../general/Icon";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
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
|
||||
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 (
|
||||
<>
|
||||
<header
|
||||
@@ -70,7 +66,7 @@ export const Header = () => {
|
||||
data-small={!isInView}
|
||||
>
|
||||
<Link href="/" aria-label="Hjem">
|
||||
<Logo christmas={isChristmas} studentHat={pigWearsHat} />
|
||||
<Logo christmas={christmas} studentHat={studentHat} />
|
||||
</Link>
|
||||
<nav className={styles.siteMenu}>
|
||||
<ul className={styles.mainMenu}>
|
||||
@@ -193,7 +189,7 @@ export const Header = () => {
|
||||
</nav>
|
||||
<div className={styles.headerBar} aria-hidden>
|
||||
<Link href="/" aria-label="Hjem" tabIndex={-1}>
|
||||
<LogoIcon face="left" christmas={isChristmas} />
|
||||
<LogoIcon face="left" christmas={christmas} />
|
||||
</Link>
|
||||
<nav className={styles.siteMenu}>
|
||||
<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 { NewsList } from "@/components/news/NewsList";
|
||||
import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news";
|
||||
@@ -11,8 +11,7 @@ export type NewsIndexViewProps = {
|
||||
export async function loadNewsIndexProps(overrides?: {
|
||||
indexOverride?: NewsIndexFragment;
|
||||
}): Promise<NewsIndexViewProps> {
|
||||
const { data, error } = await getClient().query(newsQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(newsQuery, {});
|
||||
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
|
||||
if (!index) throw new Error("Failed to load /aktuelt");
|
||||
const news = (data?.news ?? []) as NewsListItemFragment[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { NewsFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { Breadcrumb } from "@/components/general/Breadcrumb";
|
||||
import { ImageFigure } from "@/components/general/Image";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
@@ -27,10 +27,9 @@ export async function loadNewsPageProps(args: {
|
||||
return { news: args.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,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
const news = data?.news as NewsFragment | undefined;
|
||||
if (!news) return null;
|
||||
return { news };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
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 { PageContent } from "@/components/general/PageContent";
|
||||
import { SponsorList } from "@/components/sponsor/SponsorList";
|
||||
@@ -41,8 +41,7 @@ export async function loadSponsorsPageProps(overrides?: {
|
||||
if (overrides?.pageOverride) {
|
||||
return { page: overrides.pageOverride };
|
||||
}
|
||||
const { data, error } = await getClient().query(sponsorsPageQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(sponsorsPageQuery, {});
|
||||
const page = data?.page as SponsorsPageFragment | undefined;
|
||||
if (!page) throw new Error("Failed to load /sponsorer");
|
||||
return { page };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { type StudioFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
import { BgPig } from "@/components/general/BgPig";
|
||||
import { StudioHeader } from "@/components/studio/StudioHeader";
|
||||
@@ -45,8 +45,7 @@ export async function loadStudioPageProps(overrides?: {
|
||||
if (overrides?.pageOverride) {
|
||||
return { page: overrides.pageOverride };
|
||||
}
|
||||
const { data, error } = await getClient().query(studioPageQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(studioPageQuery, {});
|
||||
const page = data?.page as StudioFragment | undefined;
|
||||
if (!page) throw new Error("Failed to load /studio");
|
||||
return { page };
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { fromZonedTime } from "date-fns-tz";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "../general/Icon";
|
||||
import styles from "./studioShortcut.module.scss";
|
||||
import { startOfOsloDay } from "@/lib/date";
|
||||
import { getToday } from "@/lib/today";
|
||||
|
||||
// Hidden from this date
|
||||
// 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 = () => {
|
||||
if (new Date() >= showUntil) return null;
|
||||
export const StudioShortcut = async () => {
|
||||
if ((await getToday()) >= showUntil) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.studioShortcut}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { VenueFragment, VenueIndexFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
import { PageHeader } from "@/components/general/PageHeader";
|
||||
import { VenueList } from "@/components/venues/VenueList";
|
||||
@@ -77,8 +77,7 @@ export type VenueIndexViewProps = {
|
||||
export async function loadVenueIndexProps(overrides?: {
|
||||
indexOverride?: VenueIndexFragment;
|
||||
}): Promise<VenueIndexViewProps> {
|
||||
const { data, error } = await getClient().query(venueIndexQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(venueIndexQuery, {});
|
||||
const index = overrides?.indexOverride ?? (data?.index as VenueIndexFragment | undefined);
|
||||
if (!index) throw new Error("Failed to load /lokaler");
|
||||
const venues = (data?.venues ?? []) as VenueFragment[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { VenueFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { ImageSliderBlockFragmentDefinition } from "@/components/blocks/ImageSliderBlock";
|
||||
import { Breadcrumb } from "@/components/general/Breadcrumb";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
@@ -32,10 +32,9 @@ export async function loadVenuePageProps(args: {
|
||||
return { venue: args.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,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
const venue = data?.venue as VenueFragment | undefined;
|
||||
if (!venue) return null;
|
||||
return { venue };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { VenueFragment, VenueRentalIndexFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { BgPig } from "@/components/general/BgPig";
|
||||
import { PageContent } from "@/components/general/PageContent";
|
||||
import { PageHeader } from "@/components/general/PageHeader";
|
||||
@@ -42,8 +42,7 @@ export type VenueRentalIndexViewProps = {
|
||||
export async function loadVenueRentalIndexProps(overrides?: {
|
||||
indexOverride?: VenueRentalIndexFragment;
|
||||
}): Promise<VenueRentalIndexViewProps> {
|
||||
const { data, error } = await getClient().query(venueRentalIndexQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(venueRentalIndexQuery, {});
|
||||
const index =
|
||||
overrides?.indexOverride ?? (data?.index as VenueRentalIndexFragment | undefined);
|
||||
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 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 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 = {
|
||||
"\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 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 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.
|
||||
*/
|
||||
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) {
|
||||
return (documents as any)[source] ?? {};
|
||||
|
||||
@@ -2037,6 +2037,29 @@ export type OpeningHoursWeekBlockFragment = { monday:
|
||||
| Record<PropertyKey, never>
|
||||
| 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 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>;
|
||||
@@ -2103,3 +2126,4 @@ export const FutureEventsDocument = {"kind":"Document","definitions":[{"kind":"O
|
||||
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 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,
|
||||
formatPhoneE164,
|
||||
getSearchPath,
|
||||
randomElement,
|
||||
seededElement,
|
||||
stripHtml,
|
||||
stripWhitespace,
|
||||
unique,
|
||||
@@ -22,11 +22,25 @@ describe("getSearchPath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("randomElement", () => {
|
||||
describe("seededElement", () => {
|
||||
it("picks a member, undefined for empty", () => {
|
||||
expect(randomElement([])).toBeUndefined();
|
||||
expect(randomElement(["a"])).toBe("a");
|
||||
expect([1, 2, 3]).toContain(randomElement([1, 2, 3]));
|
||||
expect(seededElement([], "x")).toBeUndefined();
|
||||
expect(seededElement(["a"], "x")).toBe("a");
|
||||
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()}`;
|
||||
}
|
||||
|
||||
export function randomElement(array: any[]): any | undefined {
|
||||
return array.length
|
||||
? array[Math.floor(Math.random() * array.length)]
|
||||
: undefined;
|
||||
// Picks the same element for the same seed every time. Rendering code can't
|
||||
// use Math.random(): prerendering rejects it, and the pick would change
|
||||
// between the server render and the next regeneration
|
||||
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[] {
|
||||
|
||||
+22
-17
@@ -10,7 +10,8 @@ import {
|
||||
groupConsecutiveDates,
|
||||
isConsecutiveDays,
|
||||
isTodayOrFuture,
|
||||
startOfTodayInOslo,
|
||||
osloDay,
|
||||
startOfOsloDay,
|
||||
toLocalTime,
|
||||
} from "./date.ts";
|
||||
|
||||
@@ -24,6 +25,9 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// what getToday()/useToday() hand to the helpers
|
||||
const today = () => startOfOsloDay(osloDay());
|
||||
|
||||
describe("toLocalTime", () => {
|
||||
it("converts UTC to Oslo wall clock", () => {
|
||||
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", () => {
|
||||
const tz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-07-07T22:20:00Z"));
|
||||
const today = startOfTodayInOslo();
|
||||
expect(today.getDate()).toBe(8);
|
||||
expect(today.getHours()).toBe(0);
|
||||
expect(osloDay()).toBe("2026-07-08");
|
||||
const start = startOfOsloDay(osloDay());
|
||||
expect(start.getDate()).toBe(8);
|
||||
expect(start.getHours()).toBe(0);
|
||||
} finally {
|
||||
process.env.TZ = tz;
|
||||
}
|
||||
@@ -57,42 +62,42 @@ describe("formatDate", () => {
|
||||
|
||||
describe("formatYearMonth", () => {
|
||||
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", () => {
|
||||
expect(formatYearMonth("2025-11")).toBe("november 2025");
|
||||
expect(formatYearMonth("2025-11", today())).toBe("november 2025");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExtendedDateTime", () => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
expect(formatExtendedDateTime("2026-10-24T23:30:00Z")).toBe(
|
||||
expect(formatExtendedDateTime("2026-10-24T23:30:00Z", today())).toBe(
|
||||
"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"
|
||||
);
|
||||
});
|
||||
@@ -100,14 +105,14 @@ describe("formatExtendedDateTime", () => {
|
||||
|
||||
describe("isTodayOrFuture", () => {
|
||||
it("is false for yesterday, true for today and later", () => {
|
||||
expect(isTodayOrFuture("2026-07-06T10:00:00Z")).toBe(false);
|
||||
expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true);
|
||||
expect(isTodayOrFuture("2026-08-01T00:00:00Z")).toBe(true);
|
||||
expect(isTodayOrFuture("2026-07-06T10:00:00Z", today())).toBe(false);
|
||||
expect(isTodayOrFuture("2026-07-07T20:00:00Z", today())).toBe(true);
|
||||
expect(isTodayOrFuture("2026-08-01T00:00:00Z", today())).toBe(true);
|
||||
});
|
||||
|
||||
it("judges the day by Oslo wall clock, not UTC", () => {
|
||||
// 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 {
|
||||
isToday,
|
||||
isAfter,
|
||||
isBefore,
|
||||
parse,
|
||||
compareAsc,
|
||||
addDays,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
startOfDay,
|
||||
} from "date-fns";
|
||||
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";
|
||||
|
||||
const timeZone = "Europe/Oslo";
|
||||
@@ -20,19 +19,26 @@ export function toLocalTime(date: Date | string | number) {
|
||||
return toZonedTime(date, timeZone);
|
||||
}
|
||||
|
||||
// Start of the current Oslo day, regardless of the server's TZ
|
||||
export function startOfTodayInOslo() {
|
||||
return startOfDay(toLocalTime(new Date()));
|
||||
// The Oslo calendar day ("yyyy-MM-dd") at the given instant, regardless of
|
||||
// the server's TZ. Rendering code must not call this without a cache scope:
|
||||
// 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) {
|
||||
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
|
||||
const parsed = parse(yearMonth, "yyyy-MM", new Date());
|
||||
if (parsed.getFullYear() === new Date().getFullYear()) {
|
||||
const parsed = parse(yearMonth, "yyyy-MM", new Date(0));
|
||||
if (parsed.getFullYear() === today.getFullYear()) {
|
||||
return formatDate(parsed, "MMMM");
|
||||
}
|
||||
return formatDate(parsed, "MMMM yyyy");
|
||||
@@ -40,6 +46,7 @@ export function formatYearMonth(yearMonth: string) {
|
||||
|
||||
export function formatExtendedDateTime(
|
||||
date: Date | string | number,
|
||||
today: Date,
|
||||
dateOnly: boolean = false,
|
||||
alwaysIncludeYear: boolean = false
|
||||
) {
|
||||
@@ -47,21 +54,15 @@ export function formatExtendedDateTime(
|
||||
// year included if not current year
|
||||
const parsed = toLocalTime(date);
|
||||
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 formatStr = `EEEE d. MMMM${yearPart}${timePart}`;
|
||||
const formatted = format(parsed, formatStr, { timeZone, locale: nb });
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function isTodayOrFuture(
|
||||
date: Date | string | number,
|
||||
timeZone = "UTC"
|
||||
) {
|
||||
const now = new Date();
|
||||
const zonedNow = toZonedTime(now, timeZone);
|
||||
const zonedDate = toLocalTime(date);
|
||||
return isToday(zonedDate) || isAfter(zonedDate, zonedNow);
|
||||
export function isTodayOrFuture(date: Date | string | number, today: Date) {
|
||||
return !isBefore(toLocalTime(date), today);
|
||||
}
|
||||
|
||||
export function compareDates(a: Date | string, b: Date | string) {
|
||||
@@ -124,11 +125,11 @@ export function formatOccurrenceMonths(starts: string[]): string {
|
||||
|
||||
return groups
|
||||
.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) {
|
||||
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 =
|
||||
first.getFullYear() === last.getFullYear()
|
||||
? formatDate(first, "MMMM")
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
organizeEventsInCalendar,
|
||||
sortSingularEvents,
|
||||
} 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
|
||||
beforeEach(() => {
|
||||
@@ -180,7 +184,7 @@ describe("getFutureOccurrences", () => {
|
||||
"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-14T18:00:00Z",
|
||||
]);
|
||||
@@ -197,7 +201,7 @@ describe("getFutureOccurrences", () => {
|
||||
"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",
|
||||
]);
|
||||
} finally {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
toLocalTime,
|
||||
formatDate,
|
||||
compareDates,
|
||||
startOfTodayInOslo,
|
||||
} from "./date";
|
||||
import { graphql, unmaskFragment } from "@/gql";
|
||||
import {
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
type EventOrganizerFragment,
|
||||
type EventOverviewItemFragment,
|
||||
} 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 EventListItemOccurrence = EventListItemFragment["occurrences"][number];
|
||||
@@ -353,9 +352,9 @@ export function organizeEventsByDate<E extends EventListable>(
|
||||
}
|
||||
|
||||
export function getFutureOccurrences<E extends EventListable>(
|
||||
event: E
|
||||
event: E,
|
||||
today: Date
|
||||
): E["occurrences"][number][] {
|
||||
const today = startOfTodayInOslo();
|
||||
const occurrences = event?.occurrences ?? [];
|
||||
const futureOccurrences = occurrences.filter((occurrence) =>
|
||||
isAfter(toLocalTime(occurrence.start), today)
|
||||
@@ -381,7 +380,7 @@ export function getEventPig(event: EventFragment): string | null {
|
||||
const categoryPigs = categories
|
||||
?.map((category) => category.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 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";
|
||||
|
||||
// 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 {
|
||||
OpeningHoursRangeBlockFragment,
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
getTodaysOpeningHoursForFunction,
|
||||
groupOpeningHours,
|
||||
} from "./openinghours.ts";
|
||||
import { osloDay, startOfOsloDay } from "./date.ts";
|
||||
|
||||
// what getToday()/useToday() hand to the helpers
|
||||
const today = () => startOfOsloDay(osloDay());
|
||||
|
||||
const range = (
|
||||
timeFrom: string | null,
|
||||
@@ -138,28 +142,28 @@ describe("getTodaysOpeningHoursForFunction", () => {
|
||||
|
||||
it("returns today's hours", () => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Stengt for a null day", () => {
|
||||
vi.setSystemTime(new Date("2026-07-11T10:00:00Z")); // lørdag
|
||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||
"Stengt"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the custom text when set", () => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ? for unknown functions", () => {
|
||||
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", () => {
|
||||
@@ -168,12 +172,12 @@ describe("getTodaysOpeningHoursForFunction", () => {
|
||||
try {
|
||||
// 22:20 UTC on torsdag is 00:20 fredag in Oslo (CEST)
|
||||
vi.setSystemTime(new Date("2026-07-09T22:20:00Z"));
|
||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||
"15:00—01:00"
|
||||
);
|
||||
// 23:20 UTC on torsdag is 00:20 fredag in Oslo (CET)
|
||||
vi.setSystemTime(new Date("2026-01-08T23:20:00Z"));
|
||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
|
||||
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
|
||||
"15:00—01:00"
|
||||
);
|
||||
} finally {
|
||||
|
||||
+10
-13
@@ -1,8 +1,6 @@
|
||||
import { compareDesc, getISODay, isAfter, isSameDay, parseISO } from "date-fns";
|
||||
|
||||
import { getClient } from "@/app/client";
|
||||
import { startOfTodayInOslo } from "@/lib/date";
|
||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { graphql, unmaskFragment } from "@/gql";
|
||||
import type {
|
||||
OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock,
|
||||
@@ -45,19 +43,17 @@ const openingHoursQuery = graphql(`
|
||||
`);
|
||||
|
||||
export async function fetchOpeningHoursSets() {
|
||||
const { data, error } = await getClient().query(
|
||||
openingHoursQuery,
|
||||
{},
|
||||
// The footer renders today's hours on every page, so this TTL doubles as
|
||||
// the sitewide midnight refresh for futureEvents rollover
|
||||
cachedUntilOsloMidnight()
|
||||
);
|
||||
// The footer shows these on every page; a failed query must not take the
|
||||
// whole site down with it
|
||||
const data = await cmsQuery(openingHoursQuery, {}).catch((error) => {
|
||||
console.error("Failed to fetch opening hours", error);
|
||||
return undefined;
|
||||
});
|
||||
const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[];
|
||||
return sets;
|
||||
}
|
||||
|
||||
export async function getOpeningHours() {
|
||||
const today = startOfTodayInOslo();
|
||||
export async function getOpeningHours(today: Date) {
|
||||
const sets = await fetchOpeningHoursSets();
|
||||
const validSets = sets
|
||||
.filter((set) => {
|
||||
@@ -202,12 +198,13 @@ export function getPrettyOpeningHoursForFunction(
|
||||
export function getTodaysOpeningHoursForFunction(
|
||||
openingHours: OpeningHoursSet,
|
||||
name: string,
|
||||
today: Date,
|
||||
): string {
|
||||
const week: any = getOpeningHoursForFunction(openingHours, name);
|
||||
if (!week) {
|
||||
return "?";
|
||||
}
|
||||
const weekdayIndex = getISODay(startOfTodayInOslo()) - 1;
|
||||
const weekdayIndex = getISODay(today) - 1;
|
||||
const weekday = WEEKDAYS[weekdayIndex];
|
||||
const hours = week[weekday];
|
||||
if (hours?.timeFrom && hours?.timeTo) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
cachedUntilOsloMidnight,
|
||||
secondsUntilOsloMidnight,
|
||||
} from "./revalidation.ts";
|
||||
import { secondsUntilOsloMidnight } from "./revalidation.ts";
|
||||
|
||||
describe("secondsUntilOsloMidnight", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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");
|
||||
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)", () => {
|
||||
@@ -26,29 +28,3 @@ describe("secondsUntilOsloMidnight", () => {
|
||||
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 { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||
import type { OperationContext } from "@urql/core";
|
||||
|
||||
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";
|
||||
|
||||
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 {
|
||||
const osloNow = toZonedTime(now, timeZone);
|
||||
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
|
||||
const instant = fromZonedTime(nextOsloMidnight, timeZone);
|
||||
return Math.max(
|
||||
Math.ceil((instant.getTime() - now.getTime()) / 1000),
|
||||
MIN_REVALIDATE_SECONDS
|
||||
);
|
||||
return Math.ceil((instant.getTime() - now.getTime()) / 1000);
|
||||
}
|
||||
|
||||
// Per-operation contexts replace (not merge with) the client-default
|
||||
// fetchOptions, so both contexts below are self-contained
|
||||
|
||||
// For per-user (search) and token-scoped (preview) queries
|
||||
// For per-user (search) and token-scoped (preview) queries, which must
|
||||
// never be stored
|
||||
export const uncached: Partial<OperationContext> = {
|
||||
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,
|
||||
AllGenericSlugsQuery,
|
||||
AllVenueSlugsQuery,
|
||||
LatestPageSlugQuery,
|
||||
AssociationBySlugQuery,
|
||||
AssociationFragment,
|
||||
AssociationIndexFragment,
|
||||
@@ -53,6 +54,8 @@ import type {
|
||||
|
||||
export type MockState = {
|
||||
newsHeadlineVersion: "v1" | "v2";
|
||||
// how long the CMS takes to answer newsBySlug, to hold a page render open
|
||||
newsDelayMs?: number;
|
||||
};
|
||||
|
||||
export const SLUGS = {
|
||||
@@ -60,6 +63,8 @@ export const SLUGS = {
|
||||
association: "testforening",
|
||||
venue: "storsalen",
|
||||
news: "test-artikkel",
|
||||
// exists in the CMS but is not prerendered: rendered on first request
|
||||
newsOnDemand: "eldre-artikkel",
|
||||
event: "testkonsert",
|
||||
} as const;
|
||||
|
||||
@@ -98,7 +103,7 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
||||
__typename: "EventCategory",
|
||||
name: "Konsert",
|
||||
slug: "konsert",
|
||||
pig: "PIG_PINK",
|
||||
pig: "music",
|
||||
showInFilters: true,
|
||||
} satisfies EventCategoryFragment;
|
||||
|
||||
@@ -158,7 +163,8 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
||||
seoTitle: "Testkonsert",
|
||||
searchDescription: null,
|
||||
lead: "Bli med på testkonsert.",
|
||||
pig: "PIG_PINK",
|
||||
// picks a pig from the event's categories, which must be prerenderable
|
||||
pig: "automatic",
|
||||
facebookUrl: null,
|
||||
ticketUrl: null,
|
||||
free: true,
|
||||
@@ -344,6 +350,15 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
||||
({
|
||||
pages: [{ id: "30", slug: SLUGS.venue }],
|
||||
}) 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 ---
|
||||
home: (_vars, state) =>
|
||||
@@ -412,7 +427,10 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
||||
// --- runtime ---
|
||||
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,
|
||||
eventBySlug: (vars) =>
|
||||
({
|
||||
|
||||
@@ -79,6 +79,8 @@ export default async function setup(project: TestProject) {
|
||||
);
|
||||
});
|
||||
|
||||
mock.markBuildDone();
|
||||
|
||||
if (mock.graphqlRequests === 0) {
|
||||
throw new Error(
|
||||
"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;
|
||||
state: MockState;
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -34,11 +40,16 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
export async function startMockCms(): Promise<MockCms> {
|
||||
const state: MockState = { newsHeadlineVersion: "v1" };
|
||||
let fixtures: Fixtures | null = null; // built once we know our own URL
|
||||
let requestsAtBuild: Record<string, number> = {};
|
||||
|
||||
const mock: MockCms = {
|
||||
url: "",
|
||||
state,
|
||||
graphqlRequests: 0,
|
||||
requestsByOperation: {},
|
||||
markBuildDone: () => {
|
||||
requestsAtBuild = { ...mock.requestsByOperation };
|
||||
},
|
||||
close: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve()))
|
||||
@@ -56,6 +67,20 @@ export async function startMockCms(): Promise<MockCms> {
|
||||
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") {
|
||||
Object.assign(state, JSON.parse(await readBody(req)));
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
@@ -84,6 +109,10 @@ export async function startMockCms(): Promise<MockCms> {
|
||||
const name =
|
||||
body.operationName ??
|
||||
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
|
||||
if (name) {
|
||||
mock.requestsByOperation[name] =
|
||||
(mock.requestsByOperation[name] ?? 0) + 1;
|
||||
}
|
||||
const resolve = name ? fixtures?.[name] : undefined;
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
if (!resolve) {
|
||||
@@ -96,7 +125,14 @@ export async function startMockCms(): Promise<MockCms> {
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+142
-10
@@ -1,12 +1,12 @@
|
||||
// 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:
|
||||
// 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.
|
||||
|
||||
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 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() };
|
||||
}
|
||||
|
||||
async function setNewsVersion(version: "v1" | "v2"): Promise<void> {
|
||||
async function setMockState(state: Partial<MockState>): Promise<void> {
|
||||
const res = await fetch(mock("/__mock/state"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ newsHeadlineVersion: version }),
|
||||
body: JSON.stringify(state),
|
||||
});
|
||||
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", () => {
|
||||
test.each([
|
||||
["/", "Testkonsert"],
|
||||
@@ -32,7 +64,7 @@ describe("pages", () => {
|
||||
[`/${SLUGS.genericUrl}`, "Praktisk info"],
|
||||
[`/foreninger/${SLUGS.association}`, "Testforeningen"],
|
||||
[`/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"],
|
||||
[`/arrangementer/${SLUGS.event}`, "Testkonsert"],
|
||||
])("%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", () => {
|
||||
test("rejects a missing view parameter", async () => {
|
||||
const res = await fetch(app("/api/v1/events"));
|
||||
@@ -84,15 +133,18 @@ describe("image optimizer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch cache and tag revalidation", () => {
|
||||
const path = `/aktuelt/${SLUGS.news}`;
|
||||
|
||||
// Runs for a page prerendered at build time and for one rendered on its
|
||||
// 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 () => {
|
||||
await setNewsVersion("v1");
|
||||
});
|
||||
|
||||
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);
|
||||
expect(first.status).toBe(200);
|
||||
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", () => {
|
||||
test("draft mode round trip renders the preview page", async () => {
|
||||
const enable = await fetch(
|
||||
|
||||
Reference in new issue
Block a user