From 918e61d885008099787de28aa25bcf9f32c172f0 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 28 Aug 2026 19:48:17 -0700 Subject: [PATCH 1/5] Add event database caching --- lib/events/actions.integration.test.ts | 68 +++++++++++++++++++++++--- lib/events/actions.ts | 24 +++++++-- lib/events/cache.ts | 1 + lib/events/queries.ts | 64 ++++++++++++++++++++++-- 4 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 lib/events/cache.ts diff --git a/lib/events/actions.integration.test.ts b/lib/events/actions.integration.test.ts index 518a9cd..269ffdf 100644 --- a/lib/events/actions.integration.test.ts +++ b/lib/events/actions.integration.test.ts @@ -9,10 +9,35 @@ import { } from "vitest"; import { testDatabase as database } from "@/test-support/database-client"; -const { getCurrentSessionMock, revalidatePathMock } = vi.hoisted(() => ({ - getCurrentSessionMock: vi.fn(), - revalidatePathMock: vi.fn(), -})); +const { + cacheRegistrations, + getCurrentSessionMock, + revalidatePathMock, + unstableCacheMock, + updateTagMock, +} = vi.hoisted(() => { + const cacheRegistrations: Array<{ + keyParts?: string[]; + options?: { revalidate?: number | false; tags?: string[] }; + }> = []; + + return { + cacheRegistrations, + getCurrentSessionMock: vi.fn(), + revalidatePathMock: vi.fn(), + unstableCacheMock: vi.fn( + ( + callback, + keyParts?: string[], + options?: { revalidate?: number | false; tags?: string[] }, + ) => { + cacheRegistrations.push({ keyParts, options }); + return callback; + }, + ), + updateTagMock: vi.fn(), + }; +}); vi.mock("@/lib/session", async (importOriginal) => { const actual = await importOriginal(); @@ -25,6 +50,8 @@ vi.mock("@/lib/session", async (importOriginal) => { vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock, + unstable_cache: unstableCacheMock, + updateTag: updateTagMock, })); // Vitest does not resolve the package's `react-server` condition. This marker @@ -243,6 +270,7 @@ describe("event Server Actions and queries", () => { beforeEach(async () => { getCurrentSessionMock.mockReset(); revalidatePathMock.mockReset(); + updateTagMock.mockReset(); await database.exec(`DELETE FROM "event"`); }); @@ -286,6 +314,17 @@ describe("event Server Actions and queries", () => { expect(cancellation).toMatchObject({ status: "error" }); expect(events.rows).toEqual([{ count: 0 }]); expect(revalidatePathMock).not.toHaveBeenCalled(); + expect(updateTagMock).not.toHaveBeenCalled(); + }); + + it("caches approved event reads until the approved-events tag changes", () => { + expect(cacheRegistrations).toContainEqual({ + keyParts: ["events:approved"], + options: { + revalidate: false, + tags: ["events:approved"], + }, + }); }); it("inserts a one-time event as one pending parent row", async () => { @@ -421,6 +460,7 @@ describe("event Server Actions and queries", () => { ["/account"], ["/admin/events"], ]); + expect(updateTagMock).toHaveBeenCalledWith("events:approved"); revalidatePathMock.mockClear(); const repeatedApproval = await actions.moderateEvent( @@ -448,6 +488,7 @@ describe("event Server Actions and queries", () => { expect(repeatedApproval).toMatchObject({ status: "error" }); expect(revalidatePathMock).not.toHaveBeenCalled(); + expect(updateTagMock).toHaveBeenCalledTimes(1); expect(stored.rows).toEqual([ { recurrence_count: 1, @@ -528,10 +569,10 @@ describe("event Server Actions and queries", () => { ), ).toBe(false); expect(revalidatePathMock.mock.calls).toEqual([ - ["/events"], ["/account"], ["/admin/events"], ]); + expect(updateTagMock).not.toHaveBeenCalled(); }); it("lets only the original submitter invite an existing submit-capable collaborator", async () => { @@ -621,6 +662,7 @@ describe("event Server Actions and queries", () => { } revalidatePathMock.mockClear(); + updateTagMock.mockClear(); getCurrentSessionMock.mockResolvedValue(session(OTHER_USER_ID)); const cancellation = await actions.cancelEvent( eventId, @@ -646,6 +688,8 @@ describe("event Server Actions and queries", () => { ["/account"], ["/admin/events"], ]); + expect(updateTagMock).toHaveBeenCalledOnce(); + expect(updateTagMock).toHaveBeenCalledWith("events:approved"); }); it("keeps an approved series edit pending until an approver publishes it", async () => { @@ -653,6 +697,7 @@ describe("event Server Actions and queries", () => { const updatedDescription = "An approved replacement description for the complete recurring series."; revalidatePathMock.mockClear(); + updateTagMock.mockClear(); getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); const requested = await actions.requestEventEdit( @@ -712,6 +757,7 @@ describe("event Server Actions and queries", () => { "A detailed Sacramento technology community event for integration testing.", title: "Recurring Integration Series", }); + expect(updateTagMock).not.toHaveBeenCalled(); const duplicate = await actions.requestEventEdit( eventId, @@ -778,6 +824,8 @@ describe("event Server Actions and queries", () => { description: updatedDescription, title: "Updated Recurring Integration Series", }); + expect(updateTagMock).toHaveBeenCalledOnce(); + expect(updateTagMock).toHaveBeenCalledWith("events:approved"); }); it("publishes one approved occurrence override and excludes its generated base date", async () => { @@ -998,7 +1046,6 @@ describe("event Server Actions and queries", () => { ), ).toBe(false); expect(revalidatePathMock.mock.calls).toEqual([ - ["/events"], ["/account"], ["/admin/events"], ]); @@ -1102,6 +1149,7 @@ describe("event Server Actions and queries", () => { it("stores one valid occurrence cancellation and rejects a duplicate", async () => { const recurringEventId = await seedApprovedRecurringEvent(); revalidatePathMock.mockClear(); + updateTagMock.mockClear(); getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); const result = await actions.cancelEvent( @@ -1140,8 +1188,11 @@ describe("event Server Actions and queries", () => { ["/account"], ["/admin/events"], ]); + expect(updateTagMock).toHaveBeenCalledOnce(); + expect(updateTagMock).toHaveBeenCalledWith("events:approved"); revalidatePathMock.mockClear(); + updateTagMock.mockClear(); const duplicate = await actions.cancelEvent( recurringEventId, idleState, @@ -1159,6 +1210,7 @@ describe("event Server Actions and queries", () => { }); expect(count.rows).toEqual([{ count: 1 }]); expect(revalidatePathMock).not.toHaveBeenCalled(); + expect(updateTagMock).not.toHaveBeenCalled(); }); it("shows pending exceptions to admins and the event owner", async () => { @@ -1186,6 +1238,7 @@ describe("event Server Actions and queries", () => { expect(cancellation).toMatchObject({ status: "success" }); expect(pendingEvent?.canceledOccurrences).toEqual(["2026-09-10"]); expect(accountEvent?.canceledOccurrences).toEqual(["2026-09-10"]); + expect(updateTagMock).not.toHaveBeenCalled(); }); it("immediately hides a whole canceled series without changing its approval", async () => { @@ -1210,6 +1263,7 @@ describe("event Server Actions and queries", () => { weekday: 4, }); revalidatePathMock.mockClear(); + updateTagMock.mockClear(); getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); const result = await actions.cancelEvent( @@ -1247,5 +1301,7 @@ describe("event Server Actions and queries", () => { ["/account"], ["/admin/events"], ]); + expect(updateTagMock).toHaveBeenCalledOnce(); + expect(updateTagMock).toHaveBeenCalledWith("events:approved"); }); }); diff --git a/lib/events/actions.ts b/lib/events/actions.ts index 10d055a..cb0988e 100644 --- a/lib/events/actions.ts +++ b/lib/events/actions.ts @@ -1,7 +1,7 @@ "use server"; import { and, eq, isNull, or, sql } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, updateTag } from "next/cache"; import { z } from "zod"; import { db } from "@/db"; import { user } from "@/db/auth-schema"; @@ -16,6 +16,7 @@ import { import type { RecurrenceRule } from "@/app/events/types"; import { roleHasEventPermission } from "@/lib/auth-permissions"; import { SACRAMENTO_TIME_ZONE } from "@/lib/events/constants"; +import { APPROVED_EVENTS_CACHE_TAG } from "@/lib/events/cache"; import { getCurrentSession, sessionCanCancelOwnEvents, @@ -85,6 +86,11 @@ function eventAccessCondition(userId: string) { ); } +function invalidateApprovedEvents() { + updateTag(APPROVED_EVENTS_CACHE_TAG); + revalidatePath("/events"); +} + function recurrenceRuleFromRow(row: { recurrenceCount: number | null; recurrenceEndDate: string | null; @@ -669,7 +675,9 @@ export async function moderateEvent( }; } - revalidatePath("/events"); + if (moderation.data.decision === "approved") { + invalidateApprovedEvents(); + } revalidatePath("/account"); revalidatePath("/admin/events"); @@ -1001,7 +1009,9 @@ export async function moderateEventEdit( }); if (result.status === "success") { - revalidatePath("/events"); + if (moderation.data.decision === "approved") { + invalidateApprovedEvents(); + } revalidatePath("/account"); revalidatePath("/admin/events"); } @@ -1055,6 +1065,7 @@ export async function cancelEvent( } try { + let approvedEventsChanged = false; const result = await db.transaction(async (transaction) => { const [ownedEvent] = await transaction .select({ @@ -1067,6 +1078,7 @@ export async function cancelEvent( recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, recurrenceWeekdays: eventRecurrence.weekdays, startsAt: event.startsAt, + status: event.status, }) .from(event) .leftJoin(eventCollaborator, eventAccessJoin(session.user.id)) @@ -1118,6 +1130,7 @@ export async function cancelEvent( eq(eventChangeRequest.status, "pending"), ), ); + approvedEventsChanged = ownedEvent.status === "approved"; return { status: "success", @@ -1212,6 +1225,7 @@ export async function cancelEvent( eq(eventOccurrenceOverride.occurrenceDate, occurrenceDate), ), ); + approvedEventsChanged = ownedEvent.status === "approved"; return { status: "success", @@ -1220,7 +1234,9 @@ export async function cancelEvent( }); if (result.status === "success") { - revalidatePath("/events"); + if (approvedEventsChanged) { + invalidateApprovedEvents(); + } revalidatePath("/account"); revalidatePath("/admin/events"); } diff --git a/lib/events/cache.ts b/lib/events/cache.ts new file mode 100644 index 0000000..de5fcc7 --- /dev/null +++ b/lib/events/cache.ts @@ -0,0 +1 @@ +export const APPROVED_EVENTS_CACHE_TAG = "events:approved"; diff --git a/lib/events/queries.ts b/lib/events/queries.ts index 9d1b2de..6e9e24a 100644 --- a/lib/events/queries.ts +++ b/lib/events/queries.ts @@ -1,5 +1,6 @@ import "server-only"; import { and, asc, desc, eq, isNull, or, sql } from "drizzle-orm"; +import { unstable_cache } from "next/cache"; import { db } from "@/db"; import { user } from "@/db/auth-schema"; import { @@ -11,7 +12,28 @@ import { eventRecurrence, } from "@/db/schema"; import type { Event as CalendarEvent } from "@/app/events/types"; -import { mapApprovedEventsToCalendar } from "@/lib/events/mapper"; +import { APPROVED_EVENTS_CACHE_TAG } from "@/lib/events/cache"; +import { + mapApprovedEventsToCalendar, + type ApprovedEventRecord, +} from "@/lib/events/mapper"; + +type SerializedApprovedEventRecord = Omit< + ApprovedEventRecord, + "endsAt" | "occurrenceOverrides" | "startsAt" +> & { + endsAt: string; + occurrenceOverrides: Array< + Omit< + ApprovedEventRecord["occurrenceOverrides"][number], + "endsAt" | "startsAt" + > & { + endsAt: string; + startsAt: string; + } + >; + startsAt: string; +}; function groupCanceledOccurrences( rows: Array<{ eventId: string; occurrenceDate: string }>, @@ -41,7 +63,9 @@ function groupByEventId(rows: T[]) { return rowsByEvent; } -export async function getApprovedEvents(): Promise { +async function queryApprovedEventRecords(): Promise< + SerializedApprovedEventRecord[] +> { const [rows, cancellations, overrides] = await Promise.all([ db .select({ @@ -98,11 +122,43 @@ export async function getApprovedEvents(): Promise { const cancellationsByEvent = groupCanceledOccurrences(cancellations); const overridesByEvent = groupByEventId(overrides); + return rows.map((row) => ({ + ...row, + canceledOccurrenceDates: cancellationsByEvent.get(row.id) ?? [], + endsAt: row.endsAt.toISOString(), + occurrenceOverrides: (overridesByEvent.get(row.id) ?? []).map( + (override) => ({ + ...override, + endsAt: override.endsAt.toISOString(), + startsAt: override.startsAt.toISOString(), + }), + ), + startsAt: row.startsAt.toISOString(), + })); +} + +const getCachedApprovedEventRecords = unstable_cache( + queryApprovedEventRecords, + [APPROVED_EVENTS_CACHE_TAG], + { + revalidate: false, + tags: [APPROVED_EVENTS_CACHE_TAG], + }, +); + +export async function getApprovedEvents(): Promise { + const rows = await getCachedApprovedEventRecords(); + return mapApprovedEventsToCalendar( rows.map((row) => ({ ...row, - canceledOccurrenceDates: cancellationsByEvent.get(row.id) ?? [], - occurrenceOverrides: overridesByEvent.get(row.id) ?? [], + endsAt: new Date(row.endsAt), + occurrenceOverrides: row.occurrenceOverrides.map((override) => ({ + ...override, + endsAt: new Date(override.endsAt), + startsAt: new Date(override.startsAt), + })), + startsAt: new Date(row.startsAt), })), ); } From f8ab69267ffef5cc6e80e9c7ea3df0c9007a23f9 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 1 Sep 2026 15:45:10 -0700 Subject: [PATCH 2/5] chore: upgrade to PNPM 11 --- package.json | 9 ++------- pnpm-workspace.yaml | 6 ++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7002696..6f76ed4 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "name": "sac-tech-website", "version": "0.1.0", "private": true, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@11.25.0", "engines": { - "node": ">=24.0.0 <25.0.0" + "node": ">=24.0.0" }, "scripts": { "dev": "netlify dev", @@ -74,10 +74,5 @@ "typescript": "^6.0.3", "vite": "^8.2.1", "vitest": "5.0.0-beta.7" - }, - "pnpm": { - "patchedDependencies": { - "drizzle-orm@1.0.0-rc.4": "patches/drizzle-orm@1.0.0-rc.4.patch" - } } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6c750ff..f9b8bca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,8 @@ allowBuilds: + esbuild: true + netlify-cli: true + sharp: true + unix-dgram: true unrs-resolver: true +patchedDependencies: + "drizzle-orm@1.0.0-rc.4": "patches/drizzle-orm@1.0.0-rc.4.patch" From 85105235cb54df25976c2829eb516924c0b7ba9e Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 1 Sep 2026 15:47:44 -0700 Subject: [PATCH 3/5] chore: upgrade GH Actions --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71945e3..3aec192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,15 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc cache: pnpm @@ -42,7 +42,7 @@ jobs: run: pnpm exec playwright install --with-deps chromium - name: Restore Next.js build cache - uses: actions/cache@v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .next/cache key: ${{ runner.os }}-next-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('app/**/*', 'components/**/*', 'db/**/*', 'lib/**/*', 'public/**/*', 'next.config.*', 'tsconfig.json') }} From cb622333daceebf82aed17063c9e46cd69272f27 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 1 Sep 2026 15:53:20 -0700 Subject: [PATCH 4/5] chore: move to "use cache" --- app/account/page.tsx | 2 ++ app/admin/events/page.tsx | 2 ++ app/admin/users/page.tsx | 2 ++ app/auth/page.tsx | 2 ++ app/auth/reset-password/page.tsx | 2 ++ app/auth/verify-email/page.tsx | 2 ++ app/events/[eventId]/edit/page.tsx | 2 ++ app/events/page.tsx | 2 ++ app/events/submit/page.tsx | 2 ++ lib/events/actions.integration.test.ts | 42 +++++++++----------------- lib/events/queries.ts | 18 +++++------ next.config.js | 1 + 12 files changed, 41 insertions(+), 38 deletions(-) diff --git a/app/account/page.tsx b/app/account/page.tsx index e31fd2f..e8a892c 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -17,6 +17,8 @@ import { CancelEventForm } from "./cancel-event-form"; import { SignOutButton } from "./sign-out-button"; import style from "./account.module.css"; +export const instant = false; + export const metadata: Metadata = { title: "Your account", description: "Submit, edit, and manage events in your SacTech account.", diff --git a/app/admin/events/page.tsx b/app/admin/events/page.tsx index ea55e3a..ddfc96e 100644 --- a/app/admin/events/page.tsx +++ b/app/admin/events/page.tsx @@ -11,6 +11,8 @@ import { requireEventReviewerSession } from "@/lib/session"; import style from "./admin-events.module.css"; import { ModerationForm } from "./moderation-form"; +export const instant = false; + export const metadata: Metadata = { title: "Review events", description: "Review event submissions for the SacTech community calendar.", diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index f2093b5..87773cd 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -6,6 +6,8 @@ import { requireAdminSession } from "@/lib/session"; import style from "./admin-users.module.css"; import { type ManagedUser, UserManagementCard } from "./user-management-card"; +export const instant = false; + export const metadata: Metadata = { title: "Manage users", description: "Manage SacTech account roles and access.", diff --git a/app/auth/page.tsx b/app/auth/page.tsx index 0ab7a25..5c8ea7c 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -5,6 +5,8 @@ import { getCurrentSession } from "@/lib/session"; import { AuthForm } from "./auth-form"; import style from "./auth-form.module.css"; +export const instant = false; + export const metadata: Metadata = { title: "Sign in or create an account", description: diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx index 445fd36..ce62d43 100644 --- a/app/auth/reset-password/page.tsx +++ b/app/auth/reset-password/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { ResetPasswordForm } from "./reset-password-form"; import style from "../auth-form.module.css"; +export const instant = false; + export const metadata: Metadata = { title: "Reset your password", description: "Choose a new password for your SacTech account.", diff --git a/app/auth/verify-email/page.tsx b/app/auth/verify-email/page.tsx index 4be2923..40ef0aa 100644 --- a/app/auth/verify-email/page.tsx +++ b/app/auth/verify-email/page.tsx @@ -5,6 +5,8 @@ import style from "../auth-form.module.css"; const ACCOUNT_ROUTE = "/account"; +export const instant = false; + export const metadata: Metadata = { title: "Verify your email", description: "Finish verifying your SacTech account email address.", diff --git a/app/events/[eventId]/edit/page.tsx b/app/events/[eventId]/edit/page.tsx index 055d703..f7c75c3 100644 --- a/app/events/[eventId]/edit/page.tsx +++ b/app/events/[eventId]/edit/page.tsx @@ -20,6 +20,8 @@ import { CollaboratorInviteForm } from "./collaborator-invite-form"; import editStyle from "./edit-event.module.css"; import formStyle from "../../submit/event-form.module.css"; +export const instant = false; + export const metadata: Metadata = { title: "Edit event", description: "Propose changes to an event on the SacTech community calendar.", diff --git a/app/events/page.tsx b/app/events/page.tsx index f291aa2..b09f0b2 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -5,6 +5,8 @@ import { getApprovedEvents } from "@/lib/events/queries"; import { formatDateKey } from "./date-utils"; import EventsPage from "./events-page"; +export const instant = false; + export const metadata: Metadata = { title: "Events", description: diff --git a/app/events/submit/page.tsx b/app/events/submit/page.tsx index 08af972..a7cefd8 100644 --- a/app/events/submit/page.tsx +++ b/app/events/submit/page.tsx @@ -3,6 +3,8 @@ import { requireSession } from "@/lib/session"; import { EventForm } from "./event-form"; import style from "./event-form.module.css"; +export const instant = false; + export const metadata: Metadata = { title: "Submit an event", description: diff --git a/lib/events/actions.integration.test.ts b/lib/events/actions.integration.test.ts index 269ffdf..8b016f7 100644 --- a/lib/events/actions.integration.test.ts +++ b/lib/events/actions.integration.test.ts @@ -10,31 +10,17 @@ import { import { testDatabase as database } from "@/test-support/database-client"; const { - cacheRegistrations, + cacheLifeMock, + cacheTagMock, getCurrentSessionMock, revalidatePathMock, - unstableCacheMock, updateTagMock, } = vi.hoisted(() => { - const cacheRegistrations: Array<{ - keyParts?: string[]; - options?: { revalidate?: number | false; tags?: string[] }; - }> = []; - return { - cacheRegistrations, + cacheLifeMock: vi.fn(), + cacheTagMock: vi.fn(), getCurrentSessionMock: vi.fn(), revalidatePathMock: vi.fn(), - unstableCacheMock: vi.fn( - ( - callback, - keyParts?: string[], - options?: { revalidate?: number | false; tags?: string[] }, - ) => { - cacheRegistrations.push({ keyParts, options }); - return callback; - }, - ), updateTagMock: vi.fn(), }; }); @@ -49,8 +35,9 @@ vi.mock("@/lib/session", async (importOriginal) => { }); vi.mock("next/cache", () => ({ + cacheLife: cacheLifeMock, + cacheTag: cacheTagMock, revalidatePath: revalidatePathMock, - unstable_cache: unstableCacheMock, updateTag: updateTagMock, })); @@ -268,6 +255,8 @@ describe("event Server Actions and queries", () => { }); beforeEach(async () => { + cacheLifeMock.mockReset(); + cacheTagMock.mockReset(); getCurrentSessionMock.mockReset(); revalidatePathMock.mockReset(); updateTagMock.mockReset(); @@ -317,14 +306,13 @@ describe("event Server Actions and queries", () => { expect(updateTagMock).not.toHaveBeenCalled(); }); - it("caches approved event reads until the approved-events tag changes", () => { - expect(cacheRegistrations).toContainEqual({ - keyParts: ["events:approved"], - options: { - revalidate: false, - tags: ["events:approved"], - }, - }); + it("caches approved event reads until the approved-events tag changes", async () => { + await queries.getApprovedEvents(); + + expect(cacheLifeMock).toHaveBeenCalledOnce(); + expect(cacheLifeMock).toHaveBeenCalledWith({ revalidate: Infinity }); + expect(cacheTagMock).toHaveBeenCalledOnce(); + expect(cacheTagMock).toHaveBeenCalledWith("events:approved"); }); it("inserts a one-time event as one pending parent row", async () => { diff --git a/lib/events/queries.ts b/lib/events/queries.ts index 6e9e24a..e44ff17 100644 --- a/lib/events/queries.ts +++ b/lib/events/queries.ts @@ -1,6 +1,6 @@ import "server-only"; import { and, asc, desc, eq, isNull, or, sql } from "drizzle-orm"; -import { unstable_cache } from "next/cache"; +import { cacheLife, cacheTag } from "next/cache"; import { db } from "@/db"; import { user } from "@/db/auth-schema"; import { @@ -63,9 +63,14 @@ function groupByEventId(rows: T[]) { return rowsByEvent; } -async function queryApprovedEventRecords(): Promise< +async function getCachedApprovedEventRecords(): Promise< SerializedApprovedEventRecord[] > { + "use cache"; + + cacheLife({ revalidate: Infinity }); + cacheTag(APPROVED_EVENTS_CACHE_TAG); + const [rows, cancellations, overrides] = await Promise.all([ db .select({ @@ -137,15 +142,6 @@ async function queryApprovedEventRecords(): Promise< })); } -const getCachedApprovedEventRecords = unstable_cache( - queryApprovedEventRecords, - [APPROVED_EVENTS_CACHE_TAG], - { - revalidate: false, - tags: [APPROVED_EVENTS_CACHE_TAG], - }, -); - export async function getApprovedEvents(): Promise { const rows = await getCachedApprovedEventRecords(); diff --git a/next.config.js b/next.config.js index 7bafbc3..071c492 100644 --- a/next.config.js +++ b/next.config.js @@ -2,6 +2,7 @@ const devTunnelOrigin = process.env.SAC_TECH_DEV_ORIGIN; const nextConfig = { + cacheComponents: true, reactStrictMode: true, ...(devTunnelOrigin ? { allowedDevOrigins: [devTunnelOrigin] } : {}), }; From 35c19d773db1b66d634fec6beb00c9a1f0a2b871 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 1 Sep 2026 16:08:01 -0700 Subject: [PATCH 5/5] chore: migrate to use instant pages --- app/account/page.tsx | 400 ++++++++++-------- app/admin/events/page.tsx | 82 +++- app/admin/users/admin-users-content.tsx | 83 ++++ app/admin/users/page.test.ts | 8 +- app/admin/users/page.tsx | 104 ++--- app/auth/page.tsx | 24 +- app/auth/reset-password/page.tsx | 28 +- .../verify-email/page.integration.test.tsx | 8 +- app/auth/verify-email/page.tsx | 76 +--- app/auth/verify-email/verification-result.tsx | 74 ++++ app/events/[eventId]/edit/page.tsx | 331 +++++++++------ app/events/events-page.integration.test.tsx | 16 +- app/events/events-page.tsx | 250 ++++++----- app/events/page.tsx | 66 ++- app/events/submit/page.tsx | 23 +- components/site-header.tsx | 29 +- 16 files changed, 975 insertions(+), 627 deletions(-) create mode 100644 app/admin/users/admin-users-content.tsx create mode 100644 app/auth/verify-email/verification-result.tsx diff --git a/app/account/page.tsx b/app/account/page.tsx index e8a892c..792fb83 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { Suspense } from "react"; import type { RecurrenceRule } from "@/app/events/types"; import { SACRAMENTO_TIME_ZONE } from "@/lib/events/constants"; import { @@ -17,8 +18,6 @@ import { CancelEventForm } from "./cancel-event-form"; import { SignOutButton } from "./sign-out-button"; import style from "./account.module.css"; -export const instant = false; - export const metadata: Metadata = { title: "Your account", description: "Submit, edit, and manage events in your SacTech account.", @@ -91,186 +90,249 @@ function getRecurrenceRule( }; } -export default async function AccountPage() { - const session = await requireSession(); - const submissions = await getSubmissionsForUser(session.user.id); +function AccountHero({ + session, +}: { + session: Awaited>; +}) { const canReviewEvents = sessionCanReviewEvents(session); const isAdmin = sessionIsAdmin(session); + + return ( +
+
+

Your account

+

Welcome, {session.user.name}.

+

Submit events, share access, and manage their details here.

+
+
+ + Submit an event + + {canReviewEvents && ( + + Review events + + )} + {isAdmin && ( + + Manage users + + )} + +
+
+ ); +} + +async function AccountSubmissions({ userId }: { userId: string }) { + const submissions = await getSubmissionsForUser(userId); const now = new Date(); const today = formatPacificDateKey(now); return ( -
-
-
-

Your account

-

Welcome, {session.user.name}.

-

Submit events, share access, and manage their details here.

-
-
- - Submit an event - - {canReviewEvents && ( - - Review events - - )} - {isAdmin && ( - - Manage users - - )} - -
-
+
+
+

Events you manage

+
-
-
-

Events you manage

+ {submissions.length === 0 ? ( +
+

No events here yet

+

Events you submit or are invited to manage will appear here.

+ Submit an event →
- - {submissions.length === 0 ? ( -
-

No events here yet

-

Events you submit or are invited to manage will appear here.

- Submit an event → -
- ) : ( -
    - {submissions.map((submission) => { - const recurrenceSummary = formatRecurrenceSummary(submission); - const recurrenceRule = getRecurrenceRule(submission); - const nextOccurrence = - !submission.canceledAt && recurrenceRule - ? getNextFutureOccurrence( - submission.startsAt, - recurrenceRule, - now, - ) - : null; - const defaultOccurrenceDate = nextOccurrence - ? formatPacificDateKey(nextOccurrence) + ) : ( +
      + {submissions.map((submission) => { + const recurrenceSummary = formatRecurrenceSummary(submission); + const recurrenceRule = getRecurrenceRule(submission); + const nextOccurrence = + !submission.canceledAt && recurrenceRule + ? getNextFutureOccurrence( + submission.startsAt, + recurrenceRule, + now, + ) : null; - const displayStatus = submission.canceledAt - ? "canceled" - : submission.status; - const canceledOccurrences = [ - ...submission.canceledOccurrences, - ].sort(); - const pendingChanges = submission.changeRequests.filter( - (change) => change.status === "pending", - ); - const latestRejectedChange = submission.changeRequests.find( - (change) => change.status === "rejected", - ); + const defaultOccurrenceDate = nextOccurrence + ? formatPacificDateKey(nextOccurrence) + : null; + const displayStatus = submission.canceledAt + ? "canceled" + : submission.status; + const canceledOccurrences = [ + ...submission.canceledOccurrences, + ].sort(); + const pendingChanges = submission.changeRequests.filter( + (change) => change.status === "pending", + ); + const latestRejectedChange = submission.changeRequests.find( + (change) => change.status === "rejected", + ); - return ( -
    • -
      -
      -

      - {submission.isOwner - ? "Submitted by you" - : "Shared with you"} -

      -

      {submission.title}

      -

      - {dateFormatter.format(submission.startsAt)} -

      -

      - {recurrenceSummary} -

      -
      - - {statusLabels[displayStatus]} - + return ( +
    • +
      +
      +

      + {submission.isOwner + ? "Submitted by you" + : "Shared with you"} +

      +

      {submission.title}

      +

      + {dateFormatter.format(submission.startsAt)} +

      +

      + {recurrenceSummary} +

      - {submission.canceledAt && ( -

      - Canceled on {dateFormatter.format(submission.canceledAt)}. + + {statusLabels[displayStatus]} + +

      + {submission.canceledAt && ( +

      + Canceled on {dateFormatter.format(submission.canceledAt)}. +

      + )} + {pendingChanges.length > 0 && ( +
      + + {pendingChanges.length === 1 + ? "1 change is pending review" + : `${pendingChanges.length} changes are pending review`} + +

      + The approved details stay live until a reviewer accepts + each change.

      - )} - {pendingChanges.length > 0 && ( -
      - - {pendingChanges.length === 1 - ? "1 change is pending review" - : `${pendingChanges.length} changes are pending review`} - -

      - The approved details stay live until a reviewer accepts - each change. -

      -
      - )} - {latestRejectedChange?.moderationNote && ( -
      - Note about your latest edit -

      {latestRejectedChange.moderationNote}

      -
      - )} - {canceledOccurrences.length > 0 && ( -
      -

      Canceled dates

      -
        - {canceledOccurrences.map((date) => ( -
      • - -
      • - ))} -
      -
      - )} - {submission.moderationNote && ( -
      - Note from the reviewer -

      {submission.moderationNote}

      +
      + )} + {latestRejectedChange?.moderationNote && ( +
      + Note about your latest edit +

      {latestRejectedChange.moderationNote}

      +
      + )} + {canceledOccurrences.length > 0 && ( +
      +

      Canceled dates

      +
        + {canceledOccurrences.map((date) => ( +
      • + +
      • + ))} +
      +
      + )} + {submission.moderationNote && ( +
      + Note from the reviewer +

      {submission.moderationNote}

      +
      + )} + {!submission.canceledAt && ( + <> +
      + + Edit {recurrenceRule ? "the whole series" : "event"} + + {recurrenceRule && + submission.status === "approved" && + defaultOccurrenceDate && ( + + Edit one occurrence + + )}
      - )} - {!submission.canceledAt && ( - <> -
      - - Edit {recurrenceRule ? "the whole series" : "event"} - - {recurrenceRule && - submission.status === "approved" && - defaultOccurrenceDate && ( - - Edit one occurrence - - )} -
      - - - )} -
    • - ); - })} -
    - )} -
+ + + )} + + ); + })} + + )} +
+ ); +} + +function AccountHeroFallback() { + return ( +
+
+

Your account

+

Loading your account…

+

Checking your session and account access.

+
+
+ ); +} + +function AccountSubmissionsFallback() { + return ( +
+
+

Events you manage

+
+
+

Loading your events…

+

Checking for events you submitted or help manage.

+
+
+ ); +} + +async function AccountContent() { + const session = await requireSession(); + + return ( + <> + + }> + + + + ); +} + +function AccountPageFallback() { + return ( + <> + + + + ); +} + +export default function AccountPage() { + return ( +
+ }> + +
); } diff --git a/app/admin/events/page.tsx b/app/admin/events/page.tsx index ddfc96e..6abff63 100644 --- a/app/admin/events/page.tsx +++ b/app/admin/events/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import type { RecurrenceRule } from "@/app/events/types"; import { EventDescriptionMarkdown } from "@/components/event-description-markdown"; import { formatRecurrenceSummary } from "@/lib/events/format-recurrence-summary"; @@ -11,8 +12,6 @@ import { requireEventReviewerSession } from "@/lib/session"; import style from "./admin-events.module.css"; import { ModerationForm } from "./moderation-form"; -export const instant = false; - export const metadata: Metadata = { title: "Review events", description: "Review event submissions for the SacTech community calendar.", @@ -46,7 +45,7 @@ function formatCancellationDate(dateKey: string) { return cancellationDateFormatter.format(new Date(`${dateKey}T12:00:00Z`)); } -export default async function AdminEventsPage() { +async function AdminEventQueues() { await requireEventReviewerSession(); const [pendingEvents, pendingEdits] = await Promise.all([ getPendingEvents(), @@ -54,19 +53,7 @@ export default async function AdminEventsPage() { ]); return ( -
-
-
-

SacTech review team

-

Review submitted events.

-

- Review each submission and decide whether it's ready for the - public SacTech calendar. Open the event link if you need more - context. -

-
-
- + <>
@@ -423,6 +410,69 @@ export default async function AdminEventsPage() { )}
+ + ); +} + +function AdminEventQueuesFallback() { + return ( + <> +
+
+
+

Pending submissions

+
+
+
+
+

Loading submitted events…

+

Checking your review access and the submission queue.

+
+
+
+
+
+
+

Published event updates

+

Pending changes

+
+
+
+
+

Loading proposed changes…

+

Checking for edits that are waiting for review.

+
+
+
+ + ); +} + +export default function AdminEventsPage() { + return ( +
+
+
+

SacTech review team

+

Review submitted events.

+

+ Review each submission and decide whether it's ready for the + public SacTech calendar. Open the event link if you need more + context. +

+
+
+ }> + +
); } diff --git a/app/admin/users/admin-users-content.tsx b/app/admin/users/admin-users-content.tsx new file mode 100644 index 0000000..4c2a79b --- /dev/null +++ b/app/admin/users/admin-users-content.tsx @@ -0,0 +1,83 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { parseAuthRoles } from "@/lib/auth-permissions"; +import { requireAdminSession } from "@/lib/session"; +import style from "./admin-users.module.css"; +import { type ManagedUser, UserManagementCard } from "./user-management-card"; + +const PAGE_SIZE = 100; + +async function listUsers(): Promise { + const requestHeaders = await headers(); + const users: ManagedUser[] = []; + let offset = 0; + let total = Number.POSITIVE_INFINITY; + + while (offset < total) { + const result = await auth.api.listUsers({ + headers: requestHeaders, + query: { + limit: PAGE_SIZE, + offset, + sortBy: "name", + sortDirection: "asc", + }, + }); + + total = result.total; + users.push( + ...result.users.map((user) => ({ + banned: Boolean(user.banned), + email: user.email, + id: user.id, + name: user.name, + roles: parseAuthRoles(user.role), + })), + ); + + if (result.users.length === 0) { + break; + } + + offset += result.users.length; + } + + return users; +} + +export async function AdminUsersContent() { + const session = await requireAdminSession(); + const users = (await listUsers()).filter( + (user) => user.id !== session.user.id, + ); + + return ( +
+
+
+

Permissions

+

Other users

+
+

+ {users.length}{" "} + {users.length === 1 ? "user" : "users"} +

+
+ + {users.length === 0 ? ( +
+

No other users yet.

+

New accounts will appear here after they sign up.

+
+ ) : ( +
    + {users.map((user) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/admin/users/page.test.ts b/app/admin/users/page.test.ts index 42fe6f8..74aa344 100644 --- a/app/admin/users/page.test.ts +++ b/app/admin/users/page.test.ts @@ -28,9 +28,9 @@ vi.mock("./user-management-card", async () => { }; }); -import AdminUsersPage from "./page"; +import { AdminUsersContent } from "./admin-users-content"; -describe("AdminUsersPage", () => { +describe("AdminUsersContent", () => { beforeEach(() => { mocks.requireAdminSession.mockResolvedValue({ user: { id: "current-admin" }, @@ -42,7 +42,7 @@ describe("AdminUsersPage", () => { it("stops before listing users when the admin page guard denies access", async () => { mocks.requireAdminSession.mockRejectedValue(new Error("admin-required")); - await expect(AdminUsersPage()).rejects.toThrow("admin-required"); + await expect(AdminUsersContent()).rejects.toThrow("admin-required"); expect(mocks.listUsers).not.toHaveBeenCalled(); }); @@ -67,7 +67,7 @@ describe("AdminUsersPage", () => { ], }); - const markup = renderToStaticMarkup(await AdminUsersPage()); + const markup = renderToStaticMarkup(await AdminUsersContent()); expect(markup).toContain("Other User"); expect(markup).not.toContain("Current Admin"); diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 87773cd..b98d918 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,64 +1,35 @@ import type { Metadata } from "next"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; -import { parseAuthRoles } from "@/lib/auth-permissions"; -import { requireAdminSession } from "@/lib/session"; +import { Suspense } from "react"; +import { AdminUsersContent } from "./admin-users-content"; import style from "./admin-users.module.css"; -import { type ManagedUser, UserManagementCard } from "./user-management-card"; - -export const instant = false; export const metadata: Metadata = { title: "Manage users", description: "Manage SacTech account roles and access.", }; -const PAGE_SIZE = 100; - -async function listUsers(): Promise { - const requestHeaders = await headers(); - const users: ManagedUser[] = []; - let offset = 0; - let total = Number.POSITIVE_INFINITY; - - while (offset < total) { - const result = await auth.api.listUsers({ - headers: requestHeaders, - query: { - limit: PAGE_SIZE, - offset, - sortBy: "name", - sortDirection: "asc", - }, - }); - - total = result.total; - users.push( - ...result.users.map((user) => ({ - banned: Boolean(user.banned), - email: user.email, - id: user.id, - name: user.name, - roles: parseAuthRoles(user.role), - })), - ); - - if (result.users.length === 0) { - break; - } - - offset += result.users.length; - } - - return users; -} - -export default async function AdminUsersPage() { - const session = await requireAdminSession(); - const users = (await listUsers()).filter( - (user) => user.id !== session.user.id, +function AdminUsersFallback() { + return ( +
+
+
+

Permissions

+

Other users

+
+
+
+

Loading community accounts…

+

Checking your admin access and the current user list.

+
+
); +} +export default function AdminUsersPage() { return (
@@ -71,34 +42,9 @@ export default async function AdminUsersPage() {

- -
-
-
-

Permissions

-

Other users

-
-

- {users.length}{" "} - {users.length === 1 ? "user" : "users"} -

-
- - {users.length === 0 ? ( -
-

No other users yet.

-

New accounts will appear here after they sign up.

-
- ) : ( -
    - {users.map((user) => ( -
  • - -
  • - ))} -
- )} -
+ }> + +
); } diff --git a/app/auth/page.tsx b/app/auth/page.tsx index 5c8ea7c..57a1287 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -1,25 +1,39 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { isEmailDeliveryEnabled } from "@/lib/email-delivery"; import { getCurrentSession } from "@/lib/session"; import { AuthForm } from "./auth-form"; import style from "./auth-form.module.css"; -export const instant = false; - export const metadata: Metadata = { title: "Sign in or create an account", description: "Sign in to SacTech or create an account to submit community events.", }; -export default async function AuthPage() { +function AuthFormFallback() { + return ( +
+
+

Checking your account

+

We’re getting the sign-in form ready.

+
+
+ ); +} + +async function AuthFormForCurrentVisitor() { const session = await getCurrentSession(); if (session) { redirect("/account"); } + return ; +} + +export default function AuthPage() { return (
@@ -48,7 +62,9 @@ export default async function AuthPage() {
- + }> + +
diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx index ce62d43..1c24ee8 100644 --- a/app/auth/reset-password/page.tsx +++ b/app/auth/reset-password/page.tsx @@ -1,9 +1,8 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { ResetPasswordForm } from "./reset-password-form"; import style from "../auth-form.module.css"; -export const instant = false; - export const metadata: Metadata = { title: "Reset your password", description: "Choose a new password for your SacTech account.", @@ -20,13 +19,28 @@ function getSingleSearchParam(value: string | string[] | undefined) { return typeof value === "string" && value ? value : null; } -export default async function ResetPasswordPage({ - searchParams, -}: ResetPasswordPageProps) { +function ResetPasswordFallback() { + return ( +
+
+

Checking your reset link

+

We’re getting the password reset form ready.

+
+
+ ); +} + +async function ResetPasswordContent({ searchParams }: ResetPasswordPageProps) { const params = await searchParams; const error = getSingleSearchParam(params.error); const token = getSingleSearchParam(params.token); + return ; +} + +export default function ResetPasswordPage({ + searchParams, +}: ResetPasswordPageProps) { return (
- + }> + +
diff --git a/app/auth/verify-email/page.integration.test.tsx b/app/auth/verify-email/page.integration.test.tsx index ce74b67..86e31f4 100644 --- a/app/auth/verify-email/page.integration.test.tsx +++ b/app/auth/verify-email/page.integration.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; -import VerifyEmailPage from "./page"; +import { VerifyEmailResult } from "./verification-result"; const navigationMocks = vi.hoisted(() => ({ redirect: vi.fn((destination: string) => { @@ -22,9 +22,9 @@ vi.mock("next/link", () => ({ ), })); -describe("VerifyEmailPage", () => { +describe("VerifyEmailResult", () => { it("redirects a successful verification callback to the account", async () => { - await VerifyEmailPage({ searchParams: Promise.resolve({}) }).catch( + await VerifyEmailResult({ searchParams: Promise.resolve({}) }).catch( () => undefined, ); expect(navigationMocks.redirect).toHaveBeenCalledWith("/account"); @@ -50,7 +50,7 @@ describe("VerifyEmailPage", () => { "shows a recovery path for $error", async ({ error, heading, message }) => { render( - await VerifyEmailPage({ + await VerifyEmailResult({ searchParams: Promise.resolve({ error }), }), ); diff --git a/app/auth/verify-email/page.tsx b/app/auth/verify-email/page.tsx index 40ef0aa..5fd0ac0 100644 --- a/app/auth/verify-email/page.tsx +++ b/app/auth/verify-email/page.tsx @@ -1,12 +1,8 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { VerifyEmailResult } from "./verification-result"; import style from "../auth-form.module.css"; -const ACCOUNT_ROUTE = "/account"; - -export const instant = false; - export const metadata: Metadata = { title: "Verify your email", description: "Finish verifying your SacTech account email address.", @@ -18,54 +14,20 @@ interface VerifyEmailPageProps { }>; } -interface VerificationIssue { - heading: string; - message: string; -} - -function getFirstSearchParam(value: string | string[] | undefined) { - if (Array.isArray(value)) { - return value.find(Boolean) ?? null; - } - - return value || null; -} - -function getVerificationIssue(error: string): VerificationIssue { - switch (error) { - case "TOKEN_EXPIRED": - case "EXPIRED_TOKEN": - return { - heading: "Verification link expired", - message: - "This verification link has expired. Sign in again with your email and password to request a new link.", - }; - case "INVALID_TOKEN": - return { - heading: "Verification link unavailable", - message: - "This verification link is invalid or has already been used. Sign in again to request a new link if your email still needs verification.", - }; - default: - return { - heading: "We couldn't verify your email", - message: - "This verification link can't be used. Sign in again to request a new link if your email still needs verification.", - }; - } +function VerifyEmailFallback() { + return ( +
+
+

Checking your verification link

+

We’re confirming the next step for your account.

+
+
+ ); } -export default async function VerifyEmailPage({ +export default function VerifyEmailPage({ searchParams, }: VerifyEmailPageProps) { - const error = getFirstSearchParam((await searchParams).error); - - if (!error) { - redirect(ACCOUNT_ROUTE); - } - - const issue = getVerificationIssue(error); - return (
-
-
-

{issue.heading}

-

{issue.message}

-
-
- - Back to sign in - -
-
+ }> + +
diff --git a/app/auth/verify-email/verification-result.tsx b/app/auth/verify-email/verification-result.tsx new file mode 100644 index 0000000..7884b8e --- /dev/null +++ b/app/auth/verify-email/verification-result.tsx @@ -0,0 +1,74 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import style from "../auth-form.module.css"; + +const ACCOUNT_ROUTE = "/account"; + +interface VerifyEmailResultProps { + searchParams: Promise<{ + error?: string | string[]; + }>; +} + +interface VerificationIssue { + heading: string; + message: string; +} + +function getFirstSearchParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return value.find(Boolean) ?? null; + } + + return value || null; +} + +function getVerificationIssue(error: string): VerificationIssue { + switch (error) { + case "TOKEN_EXPIRED": + case "EXPIRED_TOKEN": + return { + heading: "Verification link expired", + message: + "This verification link has expired. Sign in again with your email and password to request a new link.", + }; + case "INVALID_TOKEN": + return { + heading: "Verification link unavailable", + message: + "This verification link is invalid or has already been used. Sign in again to request a new link if your email still needs verification.", + }; + default: + return { + heading: "We couldn't verify your email", + message: + "This verification link can't be used. Sign in again to request a new link if your email still needs verification.", + }; + } +} + +export async function VerifyEmailResult({ + searchParams, +}: VerifyEmailResultProps) { + const error = getFirstSearchParam((await searchParams).error); + + if (!error) { + redirect(ACCOUNT_ROUTE); + } + + const issue = getVerificationIssue(error); + + return ( +
+
+

{issue.heading}

+

{issue.message}

+
+
+ + Back to sign in + +
+
+ ); +} diff --git a/app/events/[eventId]/edit/page.tsx b/app/events/[eventId]/edit/page.tsx index f7c75c3..9f09a46 100644 --- a/app/events/[eventId]/edit/page.tsx +++ b/app/events/[eventId]/edit/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { cache, Suspense } from "react"; import { z } from "zod"; import { EventForm, @@ -20,8 +21,6 @@ import { CollaboratorInviteForm } from "./collaborator-invite-form"; import editStyle from "./edit-event.module.css"; import formStyle from "../../submit/event-form.module.css"; -export const instant = false; - export const metadata: Metadata = { title: "Edit event", description: "Propose changes to an event on the SacTech community calendar.", @@ -118,10 +117,10 @@ function formValuesFor( }; } -export default async function EditEventPage({ - params, - searchParams, -}: EditEventPageProps) { +const getEditEventContext = cache(async function getEditEventContext( + params: EditEventPageProps["params"], + searchParams: EditEventPageProps["searchParams"], +) { const session = await requireSession(); const { eventId } = await params; const query = await searchParams; @@ -131,7 +130,8 @@ export default async function EditEventPage({ } const requestedScope = firstQueryValue(query.scope); - const scope = requestedScope === "occurrence" ? "occurrence" : "series"; + const scope: "occurrence" | "series" = + requestedScope === "occurrence" ? "occurrence" : "series"; const requestedOccurrenceDate = firstQueryValue(query.occurrenceDate) ?? null; const occurrenceDate = scope === "occurrence" && @@ -149,10 +149,18 @@ export default async function EditEventPage({ notFound(); } + return { eventId, managedEvent, occurrenceDate, scope }; +}); + +async function EditEventContent({ params, searchParams }: EditEventPageProps) { + const { eventId, managedEvent, occurrenceDate, scope } = + await getEditEventContext(params, searchParams); + const recurrence = recurrenceRuleFor(managedEvent); const canEditOccurrence = managedEvent.status === "approved" && recurrence !== null; - const today = getSacramentoDateKey(new Date()); + const now = new Date(); + const today = getSacramentoDateKey(now); const nextOccurrence = recurrence ? getNextFutureOccurrence( managedEvent.startsAt, @@ -160,7 +168,7 @@ export default async function EditEventPage({ ...recurrence, excludedDates: managedEvent.canceledOccurrences, }, - new Date(), + now, ) : null; const suggestedOccurrenceDate = nextOccurrence @@ -216,7 +224,7 @@ export default async function EditEventPage({ title: managedEvent.title, } as const); - if (effectiveOccurrence.startsAt <= new Date()) { + if (effectiveOccurrence.startsAt <= now) { occurrenceUnavailableMessage = "Only future event occurrences can be edited."; effectiveOccurrence = null; @@ -247,12 +255,183 @@ export default async function EditEventPage({ scope === "occurrence" ? occurrenceDate : null, ); + return ( +
+ + +
+ {scope === "occurrence" && + canEditOccurrence && + !managedEvent.canceledAt && ( +
+

Choose the occurrence

+
+ + +
+ + +
+
+
+ )} + + {managedEvent.canceledAt ? ( +
+ This event is canceled and can no longer be edited or shared. +
+ ) : scope === "occurrence" && !canEditOccurrence ? ( +
+ Individual occurrences are available after a recurring series is + approved. +
+ ) : occurrenceUnavailableMessage ? ( +
+ {occurrenceUnavailableMessage} +
+ ) : matchingPendingRequest ? ( +
+

Changes are already waiting for review

+

+ A reviewer must approve or reject that request before another + change can be submitted for this target. +

+
+ ) : scope === "series" || occurrenceValues ? ( + <> + {matchingRejection?.moderationNote && ( +
+ Note from the reviewer +

{matchingRejection.moderationNote}

+
+ )} +
+ +
+ + ) : null} + + {managedEvent.isOwner && !managedEvent.canceledAt && ( +
+

Shared access

+

Invite another editor

+

+ Invite someone with an existing SacTech account. They can edit or + cancel this event, including individual occurrences. +

+ + {managedEvent.collaborators.length > 0 && ( +
+

People with access

+
    + {managedEvent.collaborators.map((collaborator) => ( +
  • + {collaborator.name} + {collaborator.email} +
  • + ))} +
+
+ )} +
+ )} +
+
+ ); +} + +async function EditEventTitle({ params, searchParams }: EditEventPageProps) { + const { managedEvent } = await getEditEventContext(params, searchParams); + + return

Edit {managedEvent.title}.

; +} + +function EditEventContentFallback() { + return ( +
+ + +
+
+
+

Event edit

+

Loading event details

+

Checking your access and preparing the event form.

+
+
+
+
+ ); +} + +export default function EditEventPage({ + params, + searchParams, +}: EditEventPageProps) { return (

Manage event

-

Edit {managedEvent.title}.

+ Edit your event.}> + +

Changes go to a SacTech reviewer before they replace anything already on the public calendar. @@ -261,133 +440,9 @@ export default async function EditEventPage({

-
- - -
- {scope === "occurrence" && - canEditOccurrence && - !managedEvent.canceledAt && ( -
-

Choose the occurrence

-
- - -
- - -
-
-
- )} - - {managedEvent.canceledAt ? ( -
- This event is canceled and can no longer be edited or shared. -
- ) : scope === "occurrence" && !canEditOccurrence ? ( -
- Individual occurrences are available after a recurring series is - approved. -
- ) : occurrenceUnavailableMessage ? ( -
- {occurrenceUnavailableMessage} -
- ) : matchingPendingRequest ? ( -
-

Changes are already waiting for review

-

- A reviewer must approve or reject that request before another - change can be submitted for this target. -

-
- ) : scope === "series" || occurrenceValues ? ( - <> - {matchingRejection?.moderationNote && ( -
- Note from the reviewer -

{matchingRejection.moderationNote}

-
- )} -
- -
- - ) : null} - - {managedEvent.isOwner && !managedEvent.canceledAt && ( -
-

Shared access

-

Invite another editor

-

- Invite someone with an existing SacTech account. They can edit - or cancel this event, including individual occurrences. -

- - {managedEvent.collaborators.length > 0 && ( -
-

People with access

-
    - {managedEvent.collaborators.map((collaborator) => ( -
  • - {collaborator.name} - {collaborator.email} -
  • - ))} -
-
- )} -
- )} -
-
+ }> + +
); diff --git a/app/events/events-page.integration.test.tsx b/app/events/events-page.integration.test.tsx index 63c1d6b..e945200 100644 --- a/app/events/events-page.integration.test.tsx +++ b/app/events/events-page.integration.test.tsx @@ -5,7 +5,7 @@ import { userEvent } from "vitest/browser"; import { SACRAMENTO_TIME_ZONE } from "@/lib/events/constants"; import { Calendar } from "./components/calendar/calendar"; import { RecurringEventsCard } from "./components/event-cards/recurring-event-card"; -import EventsPage from "./events-page"; +import EventsPage, { EventsCallouts } from "./events-page"; import type { Event, EventBlock, RecurrenceRule } from "./types"; function createBlock( @@ -371,9 +371,6 @@ describe("public events experience", () => { ); const filterGroup = screen.getByRole("group", { name: "Show" }); const filters = within(filterGroup); - expect( - screen.getByRole("link", { name: "Submit an event" }), - ).toHaveAttribute("href", "/account"); expect(screen.getByRole("status")).toHaveTextContent( "Showing 2 events for all events.", ); @@ -421,4 +418,15 @@ describe("public events experience", () => { screen.getByRole("button", { name: "September 5, 2026, 1 event" }), ).toBeVisible(); }); + + it("keeps the community and event-submission calls to action available", () => { + render(); + + expect( + screen.getByRole("link", { name: "Join the community" }), + ).toHaveAttribute("href", "/#join"); + expect( + screen.getByRole("link", { name: "Submit an event" }), + ).toHaveAttribute("href", "/account"); + }); }); diff --git a/app/events/events-page.tsx b/app/events/events-page.tsx index 168427c..f29c16e 100644 --- a/app/events/events-page.tsx +++ b/app/events/events-page.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { useMemo, useState } from "react"; -import { BridgeArt } from "../../components/bridge-art"; import { Calendar } from "./components/calendar/calendar"; import { NonRecurringEventsCard } from "./components/event-cards/non-recurring-event-card"; import { RecurringEventsCard } from "./components/event-cards/recurring-event-card"; @@ -45,148 +44,137 @@ export default function EventsPage({ events, referenceDate }: EventsPageProps) { "All events"; return ( -
-
-
-
-

Gather by the river

-

Find your next local tech event.

-

- Meet people across the region who design, build, teach, and learn - about technology. Browse what's scheduled now. We'll add - more SacTech gatherings as their details are confirmed. -

+ <> +
+
+
+

Community calendar

+

See what's coming up

- -
+

Use the filters and month buttons to browse the schedule.

+ + +
+ + +
+ {eventFilters.map((filter) => ( + + ))} +
+
+ +

+ Showing {filteredEvents.length}{" "} + {filteredEvents.length === 1 ? "event" : "events"} for{" "} + {activeFilterLabel.toLowerCase()}. +

+ +
-
-
-
-
-

Community calendar

-

See what's coming up

-
-

Use the filters and month buttons to browse the schedule.

-
- -
- - -
- {eventFilters.map((filter) => ( - - ))} -
-
- -

- Showing {filteredEvents.length}{" "} - {filteredEvents.length === 1 ? "event" : "events"} for{" "} - {activeFilterLabel.toLowerCase()}. -

- - + {recurringEvents.length > 0 && ( +
+

Recurring events

+
    + {recurringEvents.map((event) => ( + + ))} +
+ )} + + {specialEvents.length > 0 && ( +
+

Special events

+
    + {specialEvents.map((event) => ( + + ))} +
+
+ )} - {recurringEvents.length > 0 && ( -
-

Recurring events

-
    - {recurringEvents.map((event) => ( - - ))} -
-
- )} - - {specialEvents.length > 0 && ( -
-

Special events

-
    - {specialEvents.map((event) => ( - - ))} -
-
- )} - - {!hasEvents && ( -
-
-

Schedule update

-

- We're still confirming the next SacTech dates. -

-

- We'll add events once their dates, locations, and ways to - join are confirmed. -

-
-
- )} - + {!hasEvents && (
-

Between gatherings

-

Stay connected between events.

+

Schedule update

+

+ We're still confirming the next SacTech dates. +

- Join SacTech to meet people nearby, share what you know, and hear - when new events are announced. + We'll add events once their dates, locations, and ways to + join are confirmed.

- - Join the community -
+ )} + + ); +} -
-
-

Share an event

-

Planning a local tech event?

-

- Create an account and send us the details. A SacTech reviewer will - review the event before it appears on the calendar. -

-
- - Submit an event - -
-
-
+export function EventsCallouts() { + return ( + <> +
+
+

Between gatherings

+

Stay connected between events.

+

+ Join SacTech to meet people nearby, share what you know, and hear + when new events are announced. +

+
+ + Join the community + +
+ +
+
+

Share an event

+

Planning a local tech event?

+

+ Create an account and send us the details. A SacTech reviewer will + review the event before it appears on the calendar. +

+
+ + Submit an event + +
+ ); } diff --git a/app/events/page.tsx b/app/events/page.tsx index b09f0b2..f3841fa 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; -import { connection } from "next/server"; +import { io } from "next/cache"; +import { Suspense } from "react"; +import { BridgeArt } from "@/components/bridge-art"; import { SACRAMENTO_TIME_ZONE } from "@/lib/events/constants"; import { getApprovedEvents } from "@/lib/events/queries"; import { formatDateKey } from "./date-utils"; -import EventsPage from "./events-page"; - -export const instant = false; +import EventsPage, { EventsCallouts } from "./events-page"; +import style from "./events-page.module.css"; export const metadata: Metadata = { title: "Events", @@ -13,11 +14,64 @@ export const metadata: Metadata = { "Browse approved Sacramento technology events and check back as more dates are confirmed.", }; -export default async function EventsRoute() { - await connection(); +async function EventsResults() { + await io(); const currentSacramentoDate = formatDateKey(new Date(), SACRAMENTO_TIME_ZONE); const events = await getApprovedEvents(); return ; } + +function EventsResultsFallback() { + return ( +
+
+
+

Community calendar

+

See what's coming up

+
+

Use the filters and month buttons to browse the schedule.

+
+
+
+

Loading schedule

+

Gathering the latest event details.

+

The community calendar will be ready in a moment.

+
+
+
+ ); +} + +export default function EventsRoute() { + return ( +
+
+
+
+

Gather by the river

+

Find your next local tech event.

+

+ Meet people across the region who design, build, teach, and learn + about technology. Browse what's scheduled now. We'll add + more SacTech gatherings as their details are confirmed. +

+
+ +
+
+ +
+ }> + + + +
+
+ ); +} diff --git a/app/events/submit/page.tsx b/app/events/submit/page.tsx index a7cefd8..c34708d 100644 --- a/app/events/submit/page.tsx +++ b/app/events/submit/page.tsx @@ -1,19 +1,32 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { EventForm } from "./event-form"; import style from "./event-form.module.css"; -export const instant = false; - export const metadata: Metadata = { title: "Submit an event", description: "Submit a Sacramento technology event to the SacTech community calendar.", }; -export default async function SubmitEventPage() { +async function AuthenticatedEventForm() { await requireSession(); + return ; +} + +function EventFormFallback() { + return ( +
+

Event submission

+

Tell us about the event

+

Checking your account and preparing the event form.

+
+ ); +} + +export default function SubmitEventPage() { return (
@@ -51,7 +64,9 @@ export default async function SubmitEventPage() {
- + }> + +
diff --git a/components/site-header.tsx b/components/site-header.tsx index 8796f6e..7359294 100644 --- a/components/site-header.tsx +++ b/components/site-header.tsx @@ -1,8 +1,33 @@ import Image from "next/image"; import Link from "next/link"; +import { Suspense } from "react"; import { SiteNav } from "./site-nav"; import style from "./site-header.module.css"; +function SiteNavFallback() { + return ( + + ); +} + export function SiteHeader() { return (
@@ -17,7 +42,9 @@ export function SiteHeader() { width={72} /> - + }> + + Join the community