+
+ {/* Headline: the next thing to show up for, or how check-in works. */}
+
+
+ {nextEvent ? `Next up: ${nextEvent.title}` : "Check in at the door"}
+
+
+ {nextEvent
+ ? `${eventWhen(nextEvent.eventDate)}${nextEvent.location ? ` · ${nextEvent.location}` : ""}. Scan the event code or show your pass when you arrive.`
+ : "Nothing is on the calendar right now. At the next meeting, scan its code or show your pass to record attendance."}
+
+
+ Looking for hackathons? Registrations, teams and projects live in
+ the{" "}
+
+ hackathon hub
+
+ .
+
-
- {/* Headline: the next thing to show up for, or how check-in works. */}
-
-
- {nextEvent ? `Next up: ${nextEvent.title}` : "Check in at the door"}
-
-
- {nextEvent
- ? `${eventWhen(nextEvent.eventDate)}${nextEvent.location ? ` · ${nextEvent.location}` : ""}. Scan the event code or show your pass when you arrive.`
- : "Nothing is on the calendar right now. At the next meeting, scan its code or show your pass to record attendance."}
-
-
- Looking for hackathons? Registrations, teams and projects live in
- the{" "}
-
- hackathon hub
-
- .
-
+
+ {/* ── CLUB / HACKATHON ───────────────────────────── */}
+ {/* Two different things this org does, and they have different rules:
+ the hackathon is open to anyone with an account, the club is the
+ paid yearly membership. Splitting them is what stops the dashboard
+ reading as though everything is behind the same paywall. */}
+
+ )}
+
+
+ {/* ── MEMBERSHIP ─────────────────────────────────── */}
+ {/* Club view only, and it now also catches lapsed members: `isMember`
+ means paid AND unexpired, so the renew path is reachable instead of
+ being hidden behind the same flag that expired. */}
+ {view === "club" && memberStatus?.isMember && (
+
+
Membership active
+
+ {expiresOn ? `Runs until ${expiresOn}` : "Active"}
+ {endsSoon ? ` · ${daysLeft} days left` : ""}
+
+ {/* Renewing early extends from the current end date rather than
+ from today, so nobody loses time by paying ahead. */}
+ {endsSoon && (
+
+
+
+ )}
+
+ )}
+
+ {/* ── DESTINATIONS ───────────────────────────────── */}
+ {/* The hackathon half has no entry of its own: "Browse all" above leads
+ to the hub. It lists only the portals this person can open. */}
+ {(view === "club" ||
+ isJudge ||
+ isAdmin ||
+ !!portalContext?.isBugTester) && (
+
+ {/* Club Portal — members only */}
+ {view === "club" &&
+ (memberStatus?.isMember ? (
+
+ Club events, check-ins, and resources.
+
+ ) : (
+
+
Club Portal
+
+ {memberStatus?.hasLapsed
+ ? "Your membership has run out. Renew below to get back in."
+ : "Membership required. Join below to unlock access."}
+
+
+ ))}
+
+ {/* Projects — club side, but browsing is open so anyone can see
+ what membership actually buys before paying for it. */}
+ {view === "club" && (
+
+ Projects the club runs year-round. Join one, or pitch your own.
+
+ )}
+
+ {/* Become a Member — sits beside Projects so the club view says
+ what is missing where the rest of the club lives. The pay UI is
+ the block below; this jumps to it. */}
+ {view === "club" && !memberStatus?.isMember && !isAdmin && (
+
+ {formatCents(MEMBERSHIP_CENTS)}/year or{" "}
+ {formatCents(SEMESTER_MEMBERSHIP_CENTS)}/semester. Opens the Club
+ Portal and member resources.
+
+ )}
+
+ {/* Judge Portal — judges only */}
+ {isJudge && (
+
+ Your judging editions, where you sit and the projects you score.
+
+ )}
+
+ {/* Admin Panel — admins, and bug testers read-only */}
+ {(isAdmin || !!portalContext?.isBugTester) && (
+
+ Manage events, attendees, and analytics.
+
+ )}
+
+ )}
+
+ {/* The pay UI itself, below the destinations. The entry above is the
+ label; this is the block. */}
+ {view === "club" && !memberStatus?.isMember && !isAdmin && (
+
+
+ {memberStatus?.hasLapsed ? "Renew your membership" : "Become a member"}
+
-
- {/* ── CLUB / HACKATHON ───────────────────────────── */}
- {/* Two different things this org does, and they have different rules:
- the hackathon is open to anyone with an account, the club is the
- paid yearly membership. Splitting them is what stops the dashboard
- reading as though everything is behind the same paywall. */}
-
- )}
-
-
- {/* ── MEMBERSHIP ─────────────────────────────────── */}
- {/* Club view only, and it now also catches lapsed members: `isMember`
- means paid AND unexpired, so the renew path is reachable instead of
- being hidden behind the same flag that expired. */}
- {view === "club" && memberStatus?.isMember && (
-
-
Membership active
-
- {expiresOn ? `Runs until ${expiresOn}` : "Active"}
- {endsSoon ? ` · ${daysLeft} days left` : ""}
-
- {/* Renewing early extends from the current end date rather than
- from today, so nobody loses time by paying ahead. */}
- {endsSoon && (
-
-
-
- )}
-
- )}
-
- {/* ── DESTINATIONS ───────────────────────────────── */}
- {/* The hackathon half has no entry of its own: "Browse all" above leads
- to the hub. It lists only the portals this person can open. */}
- {(view === "club" ||
- isJudge ||
- isAdmin ||
- !!portalContext?.isBugTester) && (
-
- {/* Club Portal — members only */}
- {view === "club" &&
- (memberStatus?.isMember ? (
-
- Club events, check-ins, and resources.
-
- ) : (
-
-
Club Portal
-
- {memberStatus?.hasLapsed
- ? "Your membership has run out. Renew below to get back in."
- : "Membership required. Join below to unlock access."}
-
-
- ))}
-
- {/* Projects — club side, but browsing is open so anyone can see
- what membership actually buys before paying for it. */}
- {view === "club" && (
-
- Projects the club runs year-round. Join one, or pitch your own.
-
- )}
-
- {/* Become a Member — sits beside Projects so the club view says
- what is missing where the rest of the club lives. The pay UI is
- the block below; this jumps to it. */}
- {view === "club" && !memberStatus?.isMember && !isAdmin && (
-
- {formatCents(MEMBERSHIP_CENTS)}/year or{" "}
- {formatCents(SEMESTER_MEMBERSHIP_CENTS)}/semester. Opens the Club
- Portal and member resources.
-
- )}
-
- {/* Judge Portal — judges only */}
- {isJudge && (
-
- Your judging editions, where you sit and the projects you score.
-
- )}
-
- {/* Admin Panel — admins, and bug testers read-only */}
- {(isAdmin || !!portalContext?.isBugTester) && (
-
- Manage events, attendees, and analytics.
-
- )}
-
- )}
-
- {/* The pay UI itself, below the destinations. The entry above is the
- label; this is the block. */}
- {view === "club" && !memberStatus?.isMember && !isAdmin && (
-
-
- {memberStatus?.hasLapsed ? "Renew your membership" : "Become a member"}
-
+ {`${hackathon.currentParticipants} of ${hackathon.maxParticipants} spots taken`}
+
+ ) : null}
+ {nextStep && }
+
+
+ {/* Mounted empty so screen readers announce the line when it lands. */}
+
+ {justCheckedIn && (
+
+ You're checked in.
+
+ )}
+
+
+ {/* What organisers announced to this hacker, emailed or in app only.
+ The uuid, not the route param: that may be a slug. */}
+ {regStatus && }
+
+ {/* Tabs. The end padding lets the last tab scroll clear of the fade. */}
+
- {`${hackathon.currentParticipants} of ${hackathon.maxParticipants} spots taken`}
-
- ) : null}
- {nextStep && }
-
-
- {/* Mounted empty so screen readers announce the line when it lands. */}
-
- {justCheckedIn && (
-
- You're checked in.
-
- )}
-
-
- {/* What organisers announced to this hacker, emailed or in app only.
- The uuid, not the route param: that may be a slug. */}
- {regStatus && }
-
- {/* Tabs. The end padding lets the last tab scroll clear of the fade. */}
-
-
+ [
+ ["hackathon.getById", { id }, c.hackathon.getById({ id })],
+ ]}
+ >
+
+
);
}
diff --git a/sites/mainweb/app/(portal)/judge/JudgePage.tsx b/sites/mainweb/app/(portal)/judge/JudgePage.tsx
new file mode 100644
index 00000000..055e1bab
--- /dev/null
+++ b/sites/mainweb/app/(portal)/judge/JudgePage.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { venueFormat } from "@/lib/venue-time";
+import { useEffect } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { useRouter } from "next/navigation";
+import { ChevronRight } from "lucide-react";
+import { loginHref } from "@/lib/safe-callback";
+import { trpc } from "@/lib/trpc";
+import { useIsClient } from "@/lib/use-is-client";
+import { usePortalContext } from "@/lib/use-portal-context";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ body,
+ itemTitle,
+ label,
+ meta,
+ page,
+ pageDek,
+ pageTitle,
+ sectionRule,
+ status,
+ textLink,
+} from "@/components/portal/ui";
+
+// Statuses that take judge applications; judge.register enforces the same.
+const APPLYING = new Set(["announced", "open", "closed", "in_progress"]);
+
+function formatDates(start: Date | string, end: Date | string) {
+ const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
+ return `${venueFormat(start, opts)} – ${venueFormat(end, { ...opts, year: "numeric" })}`;
+}
+
+export default function JudgePage() {
+ const { data: session, status: authStatus } = useSession();
+ const router = useRouter();
+ const mounted = useIsClient();
+
+ // isJudge covers a judge an organiser added by email, who has no application;
+ // myApplications covers every edition the user applied to, approved or not.
+ const { data: judgeStatus, isLoading: checkingJudge } =
+ trpc.judge.isJudge.useQuery(undefined, { enabled: !!session });
+ const { data: applications, isLoading: applicationsLoading } =
+ trpc.judge.myApplications.useQuery(undefined, { enabled: !!session });
+ const { data: hackathons, isLoading: hackathonsLoading } =
+ trpc.hackathon.list.useQuery({ limit: 50 });
+ // Staff can judge any edition without applying.
+ const { data: portal } = usePortalContext();
+
+ // The approval email points here, so a signed-out visitor goes to sign in.
+ useEffect(() => {
+ if (authStatus === "unauthenticated") router.push(loginHref());
+ }, [authStatus, router]);
+
+ if (
+ !mounted ||
+ authStatus === "loading" ||
+ checkingJudge ||
+ applicationsLoading ||
+ hackathonsLoading
+ ) {
+ return ;
+ }
+
+ if (!session) return null;
+
+ const byId = new Map((hackathons ?? []).map((h) => [h.id, h]));
+
+ const judgingIds = new Set(
+ (applications ?? []).filter((a) => a.approved).map((a) => a.hackathonId),
+ );
+ if (judgeStatus?.isJudge && judgeStatus.hackathonId) {
+ judgingIds.add(judgeStatus.hackathonId);
+ }
+ if (portal?.isAdmin) {
+ for (const h of hackathons ?? []) {
+ if (h.status === "in_progress") judgingIds.add(h.id);
+ }
+ }
+ const waitingIds = new Set(
+ (applications ?? [])
+ .filter((a) => !a.approved && !judgingIds.has(a.hackathonId))
+ .map((a) => a.hackathonId),
+ );
+ const canApply = (hackathons ?? []).filter(
+ (h) =>
+ APPLYING.has(h.status) && !judgingIds.has(h.id) && !waitingIds.has(h.id),
+ );
+
+ const judging = [...judgingIds];
+ const waiting = [...waitingIds];
+
+ return (
+
+
+
Judging
+
+ {judging.length > 0
+ ? "Open your edition on judging day, then scan the QR code at your room or table to take your seat."
+ : "Apply to judge an edition. An organiser approves you before judging day."}
+
+ You will get an email when an organiser approves you.
+
+
+ )}
+
+
+
Taking judges
+ {canApply.length === 0 ? (
+
+ No edition is taking judge applications right now.
+
+ ) : (
+
+ {canApply.map((h) => (
+
+
+
{h.name}
+
+ {formatDates(h.startDate, h.endDate)}
+
+
+
+ Apply to judge
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx
index 568d5462..9cb17ba8 100644
--- a/sites/mainweb/app/(portal)/judge/page.tsx
+++ b/sites/mainweb/app/(portal)/judge/page.tsx
@@ -1,196 +1,16 @@
-"use client";
-
-import { useEffect } from "react";
-import Link from "next/link";
-import { useSession } from "next-auth/react";
-import { useRouter } from "next/navigation";
-import { ChevronRight } from "lucide-react";
-import { loginHref } from "@/lib/safe-callback";
-import { trpc } from "@/lib/trpc";
-import { useIsClient } from "@/lib/use-is-client";
-import { usePortalContext } from "@/lib/use-portal-context";
-import { LoadingScreen } from "@/components/portal/LoadingScreen";
-import {
- body,
- itemTitle,
- label,
- meta,
- page,
- pageDek,
- pageTitle,
- sectionRule,
- status,
- textLink,
-} from "@/components/portal/ui";
-
-// Statuses that take judge applications; judge.register enforces the same.
-const APPLYING = new Set(["announced", "open", "closed", "in_progress"]);
-
-function formatDates(start: Date | string, end: Date | string) {
- const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
- const s = new Date(start);
- const e = new Date(end);
- return `${s.toLocaleDateString("en-US", opts)} – ${e.toLocaleDateString("en-US", { ...opts, year: "numeric" })}`;
-}
-
-export default function JudgePage() {
- const { data: session, status: authStatus } = useSession();
- const router = useRouter();
- const mounted = useIsClient();
-
- // isJudge covers a judge an organiser added by email, who has no application;
- // myApplications covers every edition the user applied to, approved or not.
- const { data: judgeStatus, isLoading: checkingJudge } =
- trpc.judge.isJudge.useQuery(undefined, { enabled: !!session });
- const { data: applications, isLoading: applicationsLoading } =
- trpc.judge.myApplications.useQuery(undefined, { enabled: !!session });
- const { data: hackathons, isLoading: hackathonsLoading } =
- trpc.hackathon.list.useQuery({ limit: 50 });
- // Staff can judge any edition without applying.
- const { data: portal } = usePortalContext();
-
- // The approval email points here, so a signed-out visitor goes to sign in.
- useEffect(() => {
- if (authStatus === "unauthenticated") router.push(loginHref());
- }, [authStatus, router]);
-
- if (
- !mounted ||
- authStatus === "loading" ||
- checkingJudge ||
- applicationsLoading ||
- hackathonsLoading
- ) {
- return ;
- }
-
- if (!session) return null;
-
- const byId = new Map((hackathons ?? []).map((h) => [h.id, h]));
-
- const judgingIds = new Set(
- (applications ?? []).filter((a) => a.approved).map((a) => a.hackathonId),
- );
- if (judgeStatus?.isJudge && judgeStatus.hackathonId) {
- judgingIds.add(judgeStatus.hackathonId);
- }
- if (portal?.isAdmin) {
- for (const h of hackathons ?? []) {
- if (h.status === "in_progress") judgingIds.add(h.id);
- }
- }
- const waitingIds = new Set(
- (applications ?? [])
- .filter((a) => !a.approved && !judgingIds.has(a.hackathonId))
- .map((a) => a.hackathonId),
- );
- const canApply = (hackathons ?? []).filter(
- (h) =>
- APPLYING.has(h.status) && !judgingIds.has(h.id) && !waitingIds.has(h.id),
- );
-
- const judging = [...judgingIds];
- const waiting = [...waitingIds];
+import { PrefetchedPage } from "@/components/portal/PrefetchedPage";
+import JudgePage from "./JudgePage";
+export default function Page() {
return (
-
-
-
Judging
-
- {judging.length > 0
- ? "Open your edition on judging day, then scan the QR code at your room or table to take your seat."
- : "Apply to judge an edition. An organiser approves you before judging day."}
-
diff --git a/sites/mainweb/components/portal/subteams/chips.tsx b/sites/mainweb/components/portal/subteams/chips.tsx
index 2987bc0d..18427026 100644
--- a/sites/mainweb/components/portal/subteams/chips.tsx
+++ b/sites/mainweb/components/portal/subteams/chips.tsx
@@ -1,3 +1,4 @@
+import { venueFormat } from "@/lib/venue-time";
import type { SubteamApplicationStatus } from "@query/db";
import { status as statusClass } from "@/components/portal/ui";
import type { Tone } from "@/components/portal/ui";
@@ -53,7 +54,7 @@ export function SubteamChip({
}
export const shortDate = (value: Date | string) =>
- new Date(value).toLocaleDateString("en-US", {
+ venueFormat(value, {
month: "short",
day: "numeric",
year: "numeric",
diff --git a/sites/mainweb/lib/hackathon-next-step.test.ts b/sites/mainweb/lib/hackathon-next-step.test.ts
index 2b261e28..6ea1bbb9 100644
--- a/sites/mainweb/lib/hackathon-next-step.test.ts
+++ b/sites/mainweb/lib/hackathon-next-step.test.ts
@@ -519,6 +519,19 @@ describe("isWithinEditionDates", () => {
expect(isWithinEditionDates(HACKATHON, BEFORE)).toBe(false);
});
+ it("changes day at the venue's midnight, whatever zone the runtime is in", () => {
+ const edition = {
+ startDate: "2027-02-25T17:00:00Z",
+ endDate: "2027-02-27T17:00:00Z",
+ };
+ // 04:59Z is 11:59 PM on the 24th in Atlanta, though it is the 25th in Auckland.
+ expect(isWithinEditionDates(edition, new Date("2027-02-25T04:59:00Z"))).toBe(false);
+ expect(isWithinEditionDates(edition, new Date("2027-02-25T05:00:00Z"))).toBe(true);
+ // 04:59Z on the 28th is still the 27th in Atlanta, the edition's last day.
+ expect(isWithinEditionDates(edition, new Date("2027-02-28T04:59:00Z"))).toBe(true);
+ expect(isWithinEditionDates(edition, new Date("2027-02-28T05:00:00Z"))).toBe(false);
+ });
+
it("widens the start by the lead days", () => {
const threeDaysOut = new Date("2027-02-23T12:00:00Z");
expect(isWithinEditionDates(HACKATHON, threeDaysOut, 2)).toBe(false);
diff --git a/sites/mainweb/lib/hackathon-next-step.ts b/sites/mainweb/lib/hackathon-next-step.ts
index 14f9e73f..eebaa786 100644
--- a/sites/mainweb/lib/hackathon-next-step.ts
+++ b/sites/mainweb/lib/hackathon-next-step.ts
@@ -1,4 +1,5 @@
import { hackathonPathSegment } from "./hackathon-slug";
+import { venueDayNumber } from "./venue-time";
import { eventDay, eventMoment } from "../components/hackathon/judging/event-time";
/**
@@ -92,36 +93,32 @@ const formatDeadline = (at: Date) => {
return deadlineFmt.format(at);
};
-// Day granularity, on the viewer's clock: the last day counts all day.
-const startOfDay = (d: DateLike) => {
- const day = new Date(d);
- day.setHours(0, 0, 0, 0);
- return day;
-};
+// Day granularity, on the venue's calendar: the last day counts all day. The
+// server render and every viewer agree on which day it is.
+const startOfDay = venueDayNumber;
/**
* Whether today falls on the edition's days, widened by `leadDays` before it
- * starts. Viewer's clock, day granularity.
+ * starts. Venue's calendar, day granularity.
*/
export function isWithinEditionDates(
edition: { startDate: DateLike; endDate: DateLike },
now: Date,
leadDays = 0,
): boolean {
- const from = startOfDay(edition.startDate);
- from.setDate(from.getDate() - leadDays);
- const today = startOfDay(now).getTime();
- return from.getTime() <= today && today <= startOfDay(edition.endDate).getTime();
+ const from = startOfDay(edition.startDate) - leadDays;
+ const today = startOfDay(now);
+ return from <= today && today <= startOfDay(edition.endDate);
}
const isOver = (hackathon: NextStepHackathon, now: Date) =>
hackathon.status === "completed" ||
- startOfDay(hackathon.endDate).getTime() < startOfDay(now).getTime();
+ startOfDay(hackathon.endDate) < startOfDay(now);
const hasStarted = (hackathon: NextStepHackathon, now: Date) =>
hackathon.status === "in_progress" ||
hackathon.status === "completed" ||
- startOfDay(hackathon.startDate).getTime() <= startOfDay(now).getTime();
+ startOfDay(hackathon.startDate) <= startOfDay(now);
const ADMITTED = new Set(["approved", "checked_in"]);
@@ -270,7 +267,7 @@ export function hackathonNextStep(
step: "pass",
label: "Show your pass at check-in",
detail:
- startOfDay(hackathon.startDate).getTime() > startOfDay(now).getTime()
+ startOfDay(hackathon.startDate) > startOfDay(now)
? `The event starts ${eventDay(new Date(hackathon.startDate))}.`
: "Have it scanned at the door.",
href: tab("SCHEDULE"),
diff --git a/sites/mainweb/lib/portal-bootstrap.server.ts b/sites/mainweb/lib/portal-bootstrap.server.ts
new file mode 100644
index 00000000..3bf9665f
--- /dev/null
+++ b/sites/mainweb/lib/portal-bootstrap.server.ts
@@ -0,0 +1,96 @@
+import { cache } from "react";
+import { cookies, headers } from "next/headers";
+import { dehydrate, QueryClient } from "@tanstack/react-query";
+import type { Session } from "next-auth";
+import { appRouter, createContext, resolveClientIp } from "@query/api";
+
+/**
+ * The signed-in visitor's tRPC caller for this request, or null when there is
+ * none. Cached per request so the layout and a page's prefetch share one
+ * session lookup.
+ *
+ * A visitor with no session cookie (the login page, guest judges, the public
+ * hacklytics page) costs nothing here and mounts exactly as before.
+ */
+export const getPortalCaller = cache(async () => {
+ const jar = await cookies();
+ if (!jar.getAll().some((c) => c.name.endsWith("authjs.session-token"))) {
+ return null;
+ }
+ const ctx = await createContext({
+ clientIp: resolveClientIp((await headers()).get("x-forwarded-for")),
+ });
+ if (!ctx.session?.user) return null;
+ return { session: ctx.session, caller: appRouter.createCaller(ctx) };
+});
+
+export type PortalCaller = NonNullable<
+ Awaited>
+>["caller"];
+
+/** A prefetched read: `trpc..useQuery(input)` on the client hits this key. */
+export type Prefetch = readonly [
+ path: string,
+ input: unknown,
+ result: Promise,
+];
+
+/**
+ * Settles the reads and dehydrates the ones that succeeded. A failed read is
+ * left out, so the browser fetches it and reports the error as it always did.
+ */
+export async function dehydratePrefetched(entries: readonly Prefetch[]) {
+ const client = new QueryClient();
+ await Promise.all(
+ entries.map(async ([path, input, result]) => {
+ const data = await result.catch(() => undefined);
+ if (data === undefined) return;
+ const key =
+ input === undefined
+ ? [path.split("."), { type: "query" }]
+ : [path.split("."), { input, type: "query" }];
+ client.setQueryData(key, data);
+ }),
+ );
+ return dehydrate(client);
+}
+
+/**
+ * What every signed-in portal page needs before it can draw: who is signed in
+ * and the two reads the sidebar makes. Handed to the client providers, so the
+ * browser neither asks /api/auth/session for the session nor makes a tRPC
+ * round trip for the portal context after hydration.
+ */
+export async function loadPortalBootstrap() {
+ // A failed lookup (the database unreachable, say) must not take the whole
+ // portal down with it: fall back to the browser asking, as it did before.
+ const signedIn = await getPortalCaller().catch(() => null);
+ if (!signedIn) return { session: undefined, state: undefined };
+ const { caller } = signedIn;
+
+ const portal = await caller.user.getPortalContext().catch(() => null);
+ const entries: Prefetch[] = [];
+ if (portal) {
+ entries.push(["user.getPortalContext", undefined, Promise.resolve(portal)]);
+ if (!portal.isAdmin && !portal.isBugTester) {
+ entries.push([
+ "hackathon.myRegistrations",
+ undefined,
+ caller.hackathon.myRegistrations(),
+ ]);
+ }
+ }
+
+ // Only what the browser's own session carries, never the session token.
+ const { user, expires } = signedIn.session;
+ const session: Session = {
+ expires: expires ?? "",
+ user: {
+ id: user?.id,
+ name: user?.name,
+ email: user?.email,
+ image: user?.image,
+ } as Session["user"],
+ };
+ return { session, state: await dehydratePrefetched(entries) };
+}
diff --git a/sites/mainweb/lib/venue-time.test.ts b/sites/mainweb/lib/venue-time.test.ts
new file mode 100644
index 00000000..338603d5
--- /dev/null
+++ b/sites/mainweb/lib/venue-time.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import {
+ venueDayNumber,
+ venueFormat,
+ venueParts,
+} from "./venue-time";
+
+// 2026-11-06 03:30 UTC is still the evening of the 5th in Atlanta, the
+// morning of the 6th in Auckland and the afternoon of the 5th in Los Angeles.
+const LATE_NIGHT_UTC = new Date("2026-11-06T03:30:00Z");
+
+describe("venueFormat", () => {
+ it("prints the venue's day, not the runtime's", () => {
+ expect(venueFormat(LATE_NIGHT_UTC, { month: "short", day: "numeric" })).toBe(
+ "Nov 5",
+ );
+ });
+
+ it("defaults to the numeric en-US date", () => {
+ expect(venueFormat(LATE_NIGHT_UTC)).toBe("11/5/2026");
+ });
+
+ it("takes strings and timestamps", () => {
+ expect(venueFormat("2026-11-06T03:30:00Z")).toBe("11/5/2026");
+ expect(venueFormat(LATE_NIGHT_UTC.getTime())).toBe("11/5/2026");
+ });
+
+ it("follows daylight saving", () => {
+ const hour = { hour: "numeric", minute: "2-digit" } as const;
+ expect(venueFormat("2026-07-01T16:00:00Z", hour)).toBe("12:00 PM");
+ expect(venueFormat("2026-12-01T16:00:00Z", hour)).toBe("11:00 AM");
+ });
+});
+
+describe("venueParts", () => {
+ it("splits the venue's date", () => {
+ expect(venueParts(LATE_NIGHT_UTC)).toEqual({ year: 2026, month: 11, day: 5 });
+ });
+
+ it("rolls the year at the venue's midnight", () => {
+ expect(venueParts("2027-01-01T04:59:00Z").year).toBe(2026);
+ expect(venueParts("2027-01-01T05:00:00Z").year).toBe(2027);
+ });
+});
+
+describe("venueDayNumber", () => {
+ it("is the same all day at the venue and steps at its midnight", () => {
+ const before = venueDayNumber("2026-11-06T04:59:00Z");
+ const after = venueDayNumber("2026-11-06T05:00:00Z");
+ expect(after - before).toBe(1);
+ expect(venueDayNumber("2026-11-06T23:00:00Z")).toBe(after);
+ });
+
+ it("counts whole days across a month and a clock change", () => {
+ expect(
+ venueDayNumber("2026-11-08T12:00:00Z") -
+ venueDayNumber("2026-10-31T12:00:00Z"),
+ ).toBe(8);
+ });
+});
diff --git a/sites/mainweb/lib/venue-time.ts b/sites/mainweb/lib/venue-time.ts
new file mode 100644
index 00000000..3d998974
--- /dev/null
+++ b/sites/mainweb/lib/venue-time.ts
@@ -0,0 +1,59 @@
+// Portal pages render on the server and again in the browser. A date printed
+// or compared in the runtime's own zone comes out differently in the two (Cloud
+// Run is UTC), which is a hydration mismatch, and shows a remote hacker the
+// wrong day. Events run in Atlanta, so every date the portal prints and every
+// "is it today" test is on the venue's calendar, in one locale.
+export const VENUE_TZ = "America/New_York";
+
+// Built on first use, not on import: the first Intl.DateTimeFormat with a time
+// zone costs about 25 ms in a browser, and the portal navigation imports this.
+const formats = new Map();
+
+function formatter(options: Intl.DateTimeFormatOptions) {
+ const key = JSON.stringify(options);
+ let format = formats.get(key);
+ if (!format) {
+ format = new Intl.DateTimeFormat("en-US", { ...options, timeZone: VENUE_TZ });
+ formats.set(key, format);
+ }
+ return format;
+}
+
+type DateLike = Date | string | number;
+
+/**
+ * The date in en-US on the venue's calendar. No options gives "11/6/2026",
+ * what `toLocaleDateString()` prints for a visitor in the US.
+ */
+export function venueFormat(
+ value: DateLike,
+ options: Intl.DateTimeFormatOptions = {},
+): string {
+ return formatter(options).format(new Date(value));
+}
+
+/** Year, month (1-12) and day of the month on the venue's calendar. */
+export function venueParts(value: DateLike) {
+ const found = { year: 0, month: 0, day: 0 };
+ for (const part of formatter({
+ year: "numeric",
+ month: "numeric",
+ day: "numeric",
+ }).formatToParts(new Date(value))) {
+ if (part.type === "year" || part.type === "month" || part.type === "day") {
+ found[part.type] = Number(part.value);
+ }
+ }
+ return found;
+}
+
+const DAY_MS = 86_400_000;
+
+/**
+ * Which calendar day it is at the venue, as a whole number that goes up by one
+ * a day. Subtract to count days, compare to ask "is it the same day".
+ */
+export function venueDayNumber(value: DateLike): number {
+ const { year, month, day } = venueParts(value);
+ return Date.UTC(year, month - 1, day) / DAY_MS;
+}