diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index 41b09226..30d1a979 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -12,6 +12,8 @@ const mockUpdate = vi.fn(); const mockDelete = vi.fn(); // Row count returned by `select({ count })...from()`, keyed by table. const mockCount = vi.fn(); +/** maxParticipants as the capacity check reads it; undefined = uncapped. */ +const mockMaxParticipants = vi.fn<() => number | undefined>(() => undefined); // Acceptance emails are sent through a dynamic import; intercept it so we can // see exactly who a mass-approval actually mailed. const mockSendAcceptanceEmail = vi.fn(); @@ -101,7 +103,17 @@ vi.mock("@query/db", () => { }, }), select: vi.fn().mockImplementation(() => ({ - from: (tbl: any) => thenable([{ count: mockCount(tbl?._t) ?? 0 }]), + from: (tbl: any) => { + const count = mockCount(tbl?._t) ?? 0; + return thenable([ + { + count, + n: count, + max: + tbl?._t === "hackathons" ? mockMaxParticipants() : undefined, + }, + ]); + }, })), }, admins: { @@ -266,6 +278,7 @@ describe("Hackathon admin management edge cases", () => { vi.clearAllMocks(); cache.clear(); mockCount.mockReturnValue(0); + mockMaxParticipants.mockImplementation(() => undefined); mockFindMany.mockReturnValue([]); mockInsert.mockReturnValue([{}]); mockUpdate.mockReturnValue([{}]); @@ -345,6 +358,43 @@ describe("Hackathon admin management edge cases", () => { ).rejects.toThrow(/Admin access required/); }); + // maxParticipants caps acceptances. Two seats, both taken: accepting one + // more is refused whole rather than going over. + it("refuses to accept past capacity", async () => { + const caller = adminCaller({}, "admin"); + mockMaxParticipants.mockImplementation(() => 2); + const counts = [2, 1]; // seated, then newly accepted by this call + mockCount.mockImplementation((table: string) => + table === "hackathonParticipants" ? counts.shift() : 0, + ); + + await expect( + caller.hackathon.batchUpdateParticipantStatus({ + hackathonId: HACK_A, + participantIds: [PART_A1], + status: "approved", + }), + ).rejects.toThrow(/Only 0 seats left of 2/); + }); + + it("accepts while seats remain", async () => { + const caller = adminCaller({}, "admin"); + mockMaxParticipants.mockImplementation(() => 2); + const counts = [1, 1]; + mockCount.mockImplementation((table: string) => + table === "hackathonParticipants" ? counts.shift() : 0, + ); + mockUpdate.mockReturnValue([{ id: PART_A1, userId: "u1" }]); + + await expect( + caller.hackathon.batchUpdateParticipantStatus({ + hackathonId: HACK_A, + participantIds: [PART_A1], + status: "approved", + }), + ).resolves.toBeDefined(); + }); + it("lets a volunteer work a check-in desk", async () => { const caller = volunteerCaller({ hackathonEvents: { id: EVENT_A, hackathonId: HACK_A }, diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index d5b71732..8640c317 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -6,6 +6,8 @@ import { db } from "@query/db"; // Fully mock the DB at the file level. vi.mock factories are hoisted and // file-scoped, so this mirrors the shape used by routers.test.ts. const mockFindFirst = vi.fn(); +/** Rows a bare `await select().from().where()` resolves to. */ +const mockWhereRows = vi.fn<() => unknown[]>(() => [{ n: 1 }]); const mockFindMany = vi.fn(); const mockInsert = vi.fn(); const mockUpdate = vi.fn(); @@ -74,6 +76,9 @@ vi.mock("@query/db", () => { select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockImplementation(() => ({ where: vi.fn().mockImplementation(() => ({ + // Awaited directly: a count such as the judging queue check. + then: (ok: any, err: any) => + Promise.resolve(mockWhereRows()).then(ok, err), orderBy: vi.fn().mockResolvedValue([{ count: 0 }]), groupBy: vi.fn().mockResolvedValue([]), limit: vi.fn().mockResolvedValue([]), @@ -305,7 +310,9 @@ describe("Hackathon end-to-end flow", () => { ); }); - it("rejects registration when the hackathon is at capacity", async () => { + // Capacity caps acceptances, not applications: a full house still takes + // applications, and the seat is enforced when an organiser accepts. + it("takes an application when every seat is already taken", async () => { mockFindFirst.mockImplementation((table) => table === "hackathons" ? openHackathon({ maxParticipants: 500, currentParticipants: 500 }) @@ -313,9 +320,10 @@ describe("Hackathon end-to-end flow", () => { ); const caller = appRouter.createCaller(createMockCtx("user_a")); - await expect(caller.hackathon.register(registrationInput())).rejects.toThrow( - /full/, - ); + const outcome = await caller.hackathon + .register(registrationInput()) + .catch((error: unknown) => error); + expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/); }); }); @@ -541,6 +549,7 @@ describe("Hackathon end-to-end flow", () => { it("only lets an admin toggle judging on", async () => { const caller = adminCaller(); + mockWhereRows.mockReturnValueOnce([{ n: 12 }]); mockUpdate.mockReturnValue([{ judgingActive: true }]); const res = await caller.judge.toggleJudging({ @@ -550,6 +559,17 @@ describe("Hackathon end-to-end flow", () => { expect(res).toMatchObject({ success: true, judgingActive: true }); }); + // With nothing queued every judge opened straight onto "All done, 0 of 0". + it("refuses to open judging before any judge has a table", async () => { + const caller = adminCaller(); + mockWhereRows.mockReturnValueOnce([{ n: 0 }]); + + await expect( + caller.judge.toggleJudging({ hackathonId: HACK_A, active: true }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + it("refuses to let a non-admin toggle judging", async () => { mockFindFirst.mockImplementation(() => undefined); const caller = appRouter.createCaller(createMockCtx("random_user")); @@ -604,12 +624,14 @@ describe("Hackathon end-to-end flow", () => { maxMembers: 4 as const, }; - it("refuses team creation before the window opens at +12h", async () => { - const caller = teamCaller(2); + // Accepted hackers can find teammates before kickoff. + it("allows team creation before hacking starts", async () => { + const caller = teamCaller(-48); + mockInsert.mockReturnValue([{ id: "team_1", name: "meow" }]); - await expect(caller.team.createTeam(newTeam)).rejects.toThrow( - /not open yet/, - ); + await expect(caller.team.createTeam(newTeam)).resolves.toMatchObject({ + name: "meow", + }); }); it("allows team creation inside the window", async () => { @@ -649,18 +671,21 @@ describe("Hackathon end-to-end flow", () => { expect(res.canLeave).toBe(false); }); + // Teams form from acceptance, not from +12h: two hours in, an accepted + // hacker can already create, join and leave. it("reports the window so the UI can disable instead of failing", async () => { const caller = teamCaller(2); const res = await caller.team.window({ hackathonId: HACK_A }); expect(res).toMatchObject({ - isOpen: false, - canCreate: false, - canJoin: false, - canLeave: false, + isOpen: true, + canCreate: true, + canJoin: true, + canLeave: true, + opensAt: null, }); - expect(res.leaveLocksAt.getTime() - res.opensAt.getTime()).toBe( - 12 * HOUR, + expect(res.closesAt.getTime() - res.leaveLocksAt.getTime()).toBe( + 10 * HOUR, ); }); }); diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index 3e80d96c..1f8ce744 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -1264,22 +1264,23 @@ describe("Judge edge cases", () => { }); /** - * Activating and deactivating a person is the super-admin tier. A plain - * admin runs the event; deciding who holds a role does not come with that. + * Approving a judge is part of running the event, so any admin may; it + * was super-admin only while every admin saw the button. Deleting the + * judge, votes and all, stays super-admin. */ - it("refuses a plain admin, and writes nothing", async () => { + it("lets a plain admin approve a judge but not remove one", async () => { mockFindFirst.mockImplementation((table: string) => table === "admins" ? PLAIN_ADMIN_ROW : undefined, ); - await expect( - adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true }), - ).rejects.toMatchObject({ code: "FORBIDDEN" }); + const approval = await adminCaller() + .judge.setActive({ judgeId: JUDGE_ID, isActive: true }) + .catch((error: unknown) => error); + expect((approval as { code?: string })?.code).not.toBe("FORBIDDEN"); + await expect( adminCaller().judge.remove({ judgeId: JUDGE_ID }), ).rejects.toMatchObject({ code: "FORBIDDEN" }); - - expect(mockUpdate).not.toHaveBeenCalled(); expect(mockDelete).not.toHaveBeenCalled(); }); diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index 87f1b39b..a7299164 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -504,15 +504,11 @@ describe("Participant edge cases", () => { }, ); - it("lets a team form at exactly +12h but not one millisecond earlier", async () => { - const early = atOffset(12 * HOUR - 1); + // No opening time: the window is open from acceptance up to +34h. + it("lets a team form a day before hacking starts", async () => { + const early = atOffset(-24 * HOUR); await expect( early.team.createTeam({ hackathonId: HACK_A, name: "meow", maxMembers: 4 }), - ).rejects.toThrow(/not open yet/); - - const onTime = atOffset(12 * HOUR); - await expect( - onTime.team.createTeam({ hackathonId: HACK_A, name: "meow", maxMembers: 4 }), ).resolves.toMatchObject({ id: TEAM_A }); }); @@ -865,6 +861,62 @@ describe("Participant edge cases", () => { }); // ===================================================================== + // There was no way to take a registration back, so an accepted no-show + // kept a seat for good. Allowed only before you are part of the event. + describe("Withdrawing a registration", () => { + const wire = (participant: Record | undefined) => + mockFindFirst.mockImplementation((table: string) => + table === "hackathonParticipants" ? participant : undefined, + ); + + it("removes a pending applicant who has nothing attached", async () => { + wire({ id: PARTICIPANT_A, teamId: null, registrationStatus: "pending" }); + mockDelete.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + + await expect( + callerFor("user_a").hackathon.withdrawRegistration({ + hackathonId: HACK_A, + }), + ).resolves.toEqual({ success: true }); + expect(deletedTables()).toContain(hackathonParticipants); + }); + + // A badge scan or team join between the read and the delete: the + // conditions carried into the DELETE match nothing, and nothing is lost. + it("refuses when the registration changed after it was read", async () => { + wire({ id: PARTICIPANT_A, teamId: null, registrationStatus: "approved" }); + mockDelete.mockReturnValueOnce([]); + + await expect( + callerFor("user_a").hackathon.withdrawRegistration({ + hackathonId: HACK_A, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it.each([ + [ + "a team member", + { id: PARTICIPANT_A, teamId: TEAM_A, registrationStatus: "approved" }, + /Leave your team first/, + ], + [ + "somebody already checked in", + { id: PARTICIPANT_A, teamId: null, registrationStatus: "checked_in" }, + /already checked in/, + ], + ])("refuses %s", async (_label, participant, message) => { + wire(participant); + + await expect( + callerFor("user_a").hackathon.withdrawRegistration({ + hackathonId: HACK_A, + }), + ).rejects.toThrow(message); + expect(mockDelete).not.toHaveBeenCalled(); + }); + }); + describe("6. Registration under contention", () => { // BUG: the duplicate guard at registration.ts:95-108 is an unlocked // findFirst; the hackathon_participant_hackathon_user_idx violation that follows is @@ -887,9 +939,10 @@ describe("Participant edge cases", () => { }); }); - // BUG: registration.ts:110-118 reads currentParticipants with a plain - // findFirst and increments at :173 — two racers both see 499/500. - it("admits only one of two racing registrations into the last seat", async () => { + // Applying no longer claims a seat — acceptance does (admin.ts + // assertSeats) — so two applicants racing for the last seat both get in, + // and the seat count is untouched. + it("lets racing applications through without taking a seat", async () => { const hackathonRow = runningHackathon(-24, { maxParticipants: 500, currentParticipants: 499, @@ -898,30 +951,16 @@ describe("Participant edge cases", () => { table === "hackathons" ? { ...hackathonRow } : undefined, ); mockInsert.mockReturnValue([{ id: PARTICIPANT_A }]); - mockUpdate.mockImplementation((_op, updateArgs) => { - // Stands in for sql`currentParticipants + 1`, including the rollback: - // the loser of the race claims a seat and then hands it straight back - // when the re-read shows it went over. - if (updateArgs[0] === hackathons) { - hackathonRow.currentParticipants += 1; - __onRollback(() => { - hackathonRow.currentParticipants -= 1; - }); - } - return []; - }); const results = await Promise.allSettled([ callerFor("user_a").hackathon.register(registrationInput()), callerFor("user_b").hackathon.register(registrationInput()), ]); - const rejected = results.filter((r) => r.status === "rejected"); - expect(rejected).toHaveLength(1); - expect(String((rejected[0] as PromiseRejectedResult)?.reason)).toMatch( - /full/, - ); - expect(hackathonRow.currentParticipants).toBe(500); + expect(results.every((r) => r.status === "fulfilled")).toBe(true); + expect( + mockUpdate.mock.calls.some((call) => call[1]?.[0] === hackathons), + ).toBe(false); }); // BUG: registration.ts:178 calls deletePattern("hackathon*") inside the @@ -955,6 +994,7 @@ describe("Participant edge cases", () => { }; return undefined; }); + mockUpdate.mockReturnValue([{ id: PARTICIPANT_A }]); await callerFor("admin_user_id").hackathon.updateParticipantStatus({ hackathonId: HACK_A, @@ -965,6 +1005,34 @@ describe("Participant edge cases", () => { expect(updatedTables()).toContain(hackathons); }); + it("refuses a check-in when a concurrent change unseated the participant", async () => { + mockFindFirst.mockImplementation((table) => { + if (table === "admins") + return { userId: "admin_user_id", isActive: true, role: "admin" }; + if (table === "hackathonParticipants") + return { + id: PARTICIPANT_A, + hackathonId: HACK_A, + registrationStatus: "approved", + }; + return undefined; + }); + // The seated-only WHERE matched nothing: someone waitlisted them between + // the read and the write. + mockUpdate.mockReturnValue([]); + + await expect( + callerFor("admin_user_id").hackathon.updateParticipantStatus({ + hackathonId: HACK_A, + participantId: PARTICIPANT_A, + status: "checked_in", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringContaining("changed just now"), + }); + }); + // BUG: registration.ts:222-239 is a publicProcedure whose column allow-list // includes registrationStatus, and the query has no limit. it("hides who was rejected from the public roster", async () => { diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index 32a0bb17..2bbb5c03 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -100,6 +100,7 @@ vi.mock("@query/db", async () => { hackathonParticipants: table("hackathonParticipants"), hackathonTeams: table("hackathonTeams"), hackathonProjects: table("hackathonProjects"), + judgingProjects: table("judgingProjects"), hackathonEvents: table("hackathonEvents"), hackathonEventAttendees: table("hackathonEventAttendees"), members: table("members"), @@ -171,6 +172,12 @@ vi.mock("@query/db", async () => { status: "status", submittedAt: "submitted_at", }, + judgingProjects: { + id: "id", + sourceProjectId: "source_project_id", + withdrawnAt: "withdrawn_at", + tableNumber: "table_number", + }, hackathonEvents: { id: "id", hackathonId: "hackathon_id", diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index c10cf73c..66416552 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -712,10 +712,33 @@ describe("Router Integration and Access Control Verification Suite", () => { ).rejects.toThrow(); }); + // Any uuid used to pass; a missing one hit the foreign key as a 500. + it.each([ + ["a missing hackathon", null], + ["a draft", { id: "h", isPublic: true, status: "draft" }], + ["a hidden edition", { id: "h", isPublic: false, status: "open" }], + ])("refuses a judge application to %s", async (_label, hackathon) => { + const ctx = createMockCtx("applicant_user_id"); + mockFindFirst.mockImplementation((table) => + table === "hackathons" ? hackathon : null, + ); + + await expect( + appRouter.createCaller(ctx).judge.register({ + hackathonId: "00000000-0000-4000-8000-000000000001", + name: "Ada Lovelace", + email: "ada@example.com", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + it("should prevent registered participants from applying to be a judge", async () => { const ctx = createMockCtx("participant_user_id"); const hackathonId = "00000000-0000-4000-8000-000000000001"; mockFindFirst.mockImplementation((table) => { + if (table === "hackathons") { + return { id: hackathonId, isPublic: true, status: "open" }; + } if (table === "hackathonParticipants") { return { id: "participant_1", @@ -942,7 +965,7 @@ describe("Router Integration and Access Control Verification Suite", () => { describe("8. Hackathon Participant Registration (does it add users to the hackathon)", () => { const hackathonId = "00000000-0000-4000-8000-000000000010"; - it("should successfully register a user for an open hackathon and increment participant count", async () => { + it("registers a user for an open hackathon without taking a seat", async () => { const ctx = createMockCtx("new_user_id"); mockFindFirst.mockImplementation((table) => { @@ -994,7 +1017,8 @@ describe("Router Integration and Access Control Verification Suite", () => { expect(res.id).toBe("participant_new"); expect(res.userId).toBe("new_user_id"); expect(mockInsert).toHaveBeenCalled(); - expect(mockUpdate).toHaveBeenCalled(); + // Capacity counts accepted people; applying leaves the count alone. + expect(mockUpdate).not.toHaveBeenCalled(); }); it("should reject registration if user is already registered", async () => { @@ -1029,7 +1053,7 @@ describe("Router Integration and Access Control Verification Suite", () => { ).rejects.toThrowError("You are already registered for this hackathon"); }); - it("should reject registration if hackathon capacity is full", async () => { + it("takes an application even when every seat is taken", async () => { const ctx = createMockCtx("user_id"); mockFindFirst.mockImplementation((table) => { @@ -1045,8 +1069,9 @@ describe("Router Integration and Access Control Verification Suite", () => { }); const caller = appRouter.createCaller(ctx); - await expect( - caller.hackathon.register({ + // Every seat taken still takes the application: capacity is enforced + // when an organiser accepts, so the full house can feed a waitlist. + const outcome = await caller.hackathon.register({ hackathonId, firstName: "Jane", lastName: "Smith", @@ -1059,8 +1084,9 @@ describe("Router Integration and Access Control Verification Suite", () => { country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, - }), - ).rejects.toThrowError("This hackathon is full"); + }) + .catch((error: unknown) => error); + expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/); }); it("should reject registration if hackathon status is not open", async () => { diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index 65c19076..0955a4c8 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -8,6 +8,7 @@ import { createTRPCRouter } from "../../trpc"; import { isAdmin, isScanner } from "../../middleware/procedures"; import { isUniqueViolation } from "../../middleware/db-errors"; import { recordAdminAction } from "../../middleware/audit"; +import { CacheKeys } from "../../middleware/cache"; import { MASS_EMAIL_BATCH } from "../../services/email-limits"; import { hackathons, @@ -16,16 +17,22 @@ import { hackathonEventAttendees, users, } from "@query/db"; -import { eq, and, inArray, ne, sql } from "drizzle-orm"; +import { eq, and, inArray, ne, notInArray, sql } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; -// Re-derives currentParticipants from the rows that actually hold a seat. -// Rejected and waitlisted applicants do not, and registration.ts only ever -// increments, so a rejected applicant would consume capacity forever. The +// A seat belongs to somebody accepted. maxParticipants caps acceptances, not +// applications: applying is free, so an organiser can take more applications +// than seats and fill them in waves. +const SEATED_STATUSES = ["approved", "checked_in"] as const; + +// Re-derives currentParticipants from the rows that actually hold a seat. The // hackathon row is locked first: `SET x = (subquery)` plans the subquery once // per statement, so a register() committing mid-wait would be overwritten and // the seat total would drift low enough to admit people past maxParticipants. -const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) => +export const syncCurrentParticipants = ( + db: DrizzleDB, + hackathonId: string, +) => db.transaction(async (tx) => { await tx .select({ id: hackathons.id }) @@ -36,21 +43,73 @@ const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) => await tx .update(hackathons) .set({ - currentParticipants: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${hackathonId} and ${hackathonParticipants.registrationStatus} in ('pending', 'approved', 'checked_in'))`, + currentParticipants: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${hackathonId} and ${hackathonParticipants.registrationStatus} in ('approved', 'checked_in'))`, }) .where(eq(hackathons.id, hackathonId)); }); +/** + * Refuses an acceptance that would pass maxParticipants. Call inside the + * transaction that writes the new statuses: it locks the hackathon row, so two + * organisers accepting at once are counted one after the other. People already + * seated are not counted twice. + */ +async function assertSeats( + tx: DrizzleDB, + hackathonId: string, + participantIds: string[], +) { + const [hackathon] = await tx + .select({ max: hackathons.maxParticipants }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)) + .for("update"); + if (!hackathon?.max || participantIds.length === 0) return; + + const [seated] = await tx + .select({ n: sql`count(*)::int` }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + inArray(hackathonParticipants.registrationStatus, [...SEATED_STATUSES]), + ), + ); + const [adding] = await tx + .select({ n: sql`count(*)::int` }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + inArray(hackathonParticipants.id, participantIds), + notInArray(hackathonParticipants.registrationStatus, [ + ...SEATED_STATUSES, + ]), + ), + ); + + const left = Math.max(hackathon.max - (seated?.n ?? 0), 0); + if ((adding?.n ?? 0) > left) { + throw new TRPCError({ + code: "CONFLICT", + message: `Only ${left} seat${left === 1 ? "" : "s"} left of ${hackathon.max}; this would accept ${adding?.n ?? 0}. Raise the capacity or accept fewer.`, + }); + } +} + // Evicts exactly the keys a participant status change moves. The old // `deletePattern("hackathon*")` matched both namespaces, so one badge scan // wiped every attendee's cached registrations and the venue-wide events list. // Each affected user's own registration list goes too, or an acceptance lands -// in their inbox while their dashboard still says pending. -const evictParticipantCaches = ( +// in their inbox while their dashboard still says pending. The hackathon row +// goes as well: every caller has just synced currentParticipants, and getById +// serves the public "spots taken" line from that cached row. +export const evictParticipantCaches = ( cache: { delete: (key: string) => boolean }, hackathonId: string, userIds: string[], ) => { + cache.delete(CacheKeys.hackathon(hackathonId)); cache.delete(`hackathon:${hackathonId}:participants`); cache.delete(`hackathon:${hackathonId}:analytics`); for (const userId of new Set(userIds)) { @@ -248,17 +307,46 @@ export const hackathonAdminRouter = createTRPCRouter({ }); } - await (ctx.db as DrizzleDB) - .update(hackathonParticipants) - .set({ - registrationStatus: input.status, - // Stamp the arrival the first time only: re-checking someone in must not move - // the timestamp the attendees table shows. - ...(input.status === "checked_in" && !participant.checkedInAt - ? { checkedInAt: new Date() } - : {}), - }) - .where(eq(hackathonParticipants.id, input.participantId)); + await (ctx.db as DrizzleDB).transaction(async (tx) => { + // checked_in seats somebody too: the approved-only check above was + // read before this transaction, and a concurrent "waitlisted" would + // otherwise be checked in with no seat. + if (input.status === "approved" || input.status === "checked_in") { + await assertSeats(tx as unknown as DrizzleDB, input.hackathonId, [ + input.participantId, + ]); + } + const updated = await tx + .update(hackathonParticipants) + .set({ + registrationStatus: input.status, + // Stamp the arrival the first time only: re-checking someone in must not + // move the timestamp the attendees table shows. + ...(input.status === "checked_in" && !participant.checkedInAt + ? { checkedInAt: new Date() } + : {}), + }) + .where( + and( + eq(hackathonParticipants.id, input.participantId), + input.status === "checked_in" + ? inArray(hackathonParticipants.registrationStatus, [ + ...SEATED_STATUSES, + ]) + : undefined, + ), + ) + .returning({ id: hackathonParticipants.id }); + // A concurrent waitlist or reject leaves the seated-only WHERE matching + // nothing; reporting success would wave them through the desk. + if (updated.length === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This applicant's status changed just now. Refresh and check again before letting them in.", + }); + } + }); await syncCurrentParticipants(ctx.db as DrizzleDB, input.hackathonId); @@ -339,7 +427,7 @@ export const hackathonAdminRouter = createTRPCRouter({ }); } - const { wave, picked } = await db.transaction(async (tx) => { + const { wave, picked, full } = await db.transaction(async (tx) => { // Serialises waves for this hackathon. SKIP LOCKED below keeps two // concurrent waves from picking the same people, but both still read // the same max and were recorded as one wave number. @@ -360,6 +448,19 @@ export const hackathonAdminRouter = createTRPCRouter({ const wave = (highest?.max ?? 0) + 1; + // Never past capacity: a wave fills what is left and no more. + const [seats] = await tx + .select({ + max: hackathons.maxParticipants, + seated: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${input.hackathonId} and ${hackathonParticipants.registrationStatus} in ('approved', 'checked_in'))`, + }) + .from(hackathons) + .where(eq(hackathons.id, input.hackathonId)); + const size = seats?.max + ? Math.min(input.size, Math.max(seats.max - seats.seated, 0)) + : input.size; + if (size === 0) return { wave, picked: [], full: true }; + const picked = await tx .select({ id: hackathonParticipants.id, @@ -373,10 +474,10 @@ export const hackathonAdminRouter = createTRPCRouter({ ), ) .orderBy(hackathonParticipants.registeredAt) - .limit(input.size) + .limit(size) .for("update", { skipLocked: true }); - if (picked.length === 0) return { wave, picked }; + if (picked.length === 0) return { wave, picked, full: false }; await tx .update(hackathonParticipants) @@ -392,7 +493,7 @@ export const hackathonAdminRouter = createTRPCRouter({ ), ); - return { wave, picked }; + return { wave, picked, full: false }; }); if (picked.length === 0) { @@ -400,7 +501,9 @@ export const hackathonAdminRouter = createTRPCRouter({ wave, accepted: 0, participantIds: [] as string[], - message: "No pending applications left to accept.", + message: full + ? "Every seat is taken. Raise the capacity to accept more." + : "No pending applications left to accept.", }; } @@ -493,22 +596,30 @@ export const hackathonAdminRouter = createTRPCRouter({ // One statement rather than one per recipient: this runs against the full // accepted list, and a 500-round-trip transaction holds a pool connection for - // its whole duration. - await db - .update(hackathonParticipants) - .set({ registrationStatus: "approved", updatedAt: new Date() }) - .where( - and( - inArray( - hackathonParticipants.id, - participants.map((participant) => participant.id), - ), - eq(hackathonParticipants.hackathonId, hackathonId), - // Repeated in SQL for a check-in that lands after the read above: - // submitting a project requires checked_in. - ne(hackathonParticipants.registrationStatus, "checked_in"), - ), + // its whole duration. Checked against capacity first, in the same + // transaction, so the batch is accepted whole or not at all. + await db.transaction(async (tx) => { + await assertSeats( + tx as unknown as DrizzleDB, + hackathonId, + toAccept.map((participant) => participant.id), ); + await tx + .update(hackathonParticipants) + .set({ registrationStatus: "approved", updatedAt: new Date() }) + .where( + and( + inArray( + hackathonParticipants.id, + participants.map((participant) => participant.id), + ), + eq(hackathonParticipants.hackathonId, hackathonId), + // Repeated in SQL for a check-in that lands after the read above: + // submitting a project requires checked_in. + ne(hackathonParticipants.registrationStatus, "checked_in"), + ), + ); + }); // Approving a rejected or waitlisted applicant hands a seat back out. await syncCurrentParticipants(db, hackathonId); @@ -623,41 +734,50 @@ export const hackathonAdminRouter = createTRPCRouter({ // One statement, not one per id: 2000 sequential round trips would hold a // pool connection for the whole batch. The (id, hackathonId) scoping survives // in the AND, and the caller is told how many rows really changed. - const rows = await (ctx.db as DrizzleDB) - .update(hackathonParticipants) - .set({ - registrationStatus: status, - updatedAt: new Date(), - // coalesce so re-checking in someone who already arrived keeps their original - // time. `at time zone 'utc'` because the column is timestamp-without-tz: a - // bare now() goes through the session TimeZone and would disagree with the - // `new Date()` updateParticipantStatus writes for the same event. - ...(status === "checked_in" - ? { - checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`, - } - : {}), - }) - .where( - and( - inArray(hackathonParticipants.id, participantIds), - eq(hackathonParticipants.hackathonId, hackathonId), - // Same rule as the single-participant path, in the WHERE so a 2000-row - // selection with a few unreviewed applicants still admits everybody else. - // It matters more here: "Select all N matching" means one wrong click could - // promote every pending applicant to a state that lets them submit. - status === "checked_in" - ? inArray(hackathonParticipants.registrationStatus, [ - "approved", - "checked_in", - ]) - : undefined, - ), - ) - .returning({ - id: hackathonParticipants.id, - userId: hackathonParticipants.userId, - }); + const rows = await (ctx.db as DrizzleDB).transaction(async (tx) => { + if (status === "approved") { + await assertSeats( + tx as unknown as DrizzleDB, + hackathonId, + participantIds, + ); + } + return tx + .update(hackathonParticipants) + .set({ + registrationStatus: status, + updatedAt: new Date(), + // coalesce so re-checking in someone who already arrived keeps their original + // time. `at time zone 'utc'` because the column is timestamp-without-tz: a + // bare now() goes through the session TimeZone and would disagree with the + // `new Date()` updateParticipantStatus writes for the same event. + ...(status === "checked_in" + ? { + checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`, + } + : {}), + }) + .where( + and( + inArray(hackathonParticipants.id, participantIds), + eq(hackathonParticipants.hackathonId, hackathonId), + // Same rule as the single-participant path, in the WHERE so a 2000-row + // selection with a few unreviewed applicants still admits everybody else. + // It matters more here: "Select all N matching" means one wrong click could + // promote every pending applicant to a state that lets them submit. + status === "checked_in" + ? inArray(hackathonParticipants.registrationStatus, [ + "approved", + "checked_in", + ]) + : undefined, + ), + ) + .returning({ + id: hackathonParticipants.id, + userId: hackathonParticipants.userId, + }); + }); await syncCurrentParticipants(ctx.db as DrizzleDB, hackathonId); diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index 22007f4d..f37be48f 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -59,7 +59,12 @@ const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; // meant that the moment registration opened, the one page telling the world // about the hackathon said "Nothing announced yet". Soonest first, so // announcing the year after next does not displace the one being promoted. -const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const; +const PUBLIC_FUNNEL_STATUSES = [ + "announced", + "open", + "closed", + "in_progress", +] as const; /** What the landing page reads; also the shape held in the cache. */ type UpcomingEdition = { diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index 84b90b30..e4db769a 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -5,11 +5,13 @@ import { CacheKeys } from "../../middleware/cache"; import { hackathons, hackathonParticipants, + hackathonProjects, members, } from "@query/db"; -import { eq, and, sql } from "drizzle-orm"; +import { eq, and, isNull, ne } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; import { assertHackathonVisible } from "./visibility"; +import { evictParticipantCaches, syncCurrentParticipants } from "./admin"; // Postgres unique_violation on hackathon_participant_hackathon_user_idx — a second // submission of the same form. Drizzle wraps every driver error in a @@ -51,7 +53,13 @@ export const hackathonRegistrationRouter = createTRPCRouter({ // Academic info school: z.string().min(1).max(300), major: z.string().min(1).max(300), - graduationYear: z.number().int().min(2020).max(2035), + // Relative to now: a fixed 2020-2035 accepted years already past and + // would start refusing real students in 2036. + graduationYear: z + .number() + .int() + .min(new Date().getFullYear() - 1) + .max(new Date().getFullYear() + 8), levelOfStudy: z.enum([ "Freshman", "Sophomore", @@ -138,18 +146,9 @@ export const hackathonRegistrationRouter = createTRPCRouter({ }); } - // Nothing is locked yet, so this only turns away a form submitted against an - // event that was already visibly full; the seat is claimed and checked below. - if ( - hackathon.maxParticipants && - hackathon.currentParticipants >= hackathon.maxParticipants - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This hackathon is full", - }); - } - + // No seat is claimed here: capacity counts accepted people, not + // applications, so applying is never refused for being full. The + // seat is taken when an organiser accepts (admin.ts assertSeats). // A membership is annual and edition-independent, so it is keyed on the // person alone; the edition clause used to be here and made a paying member // read as a non-member the moment a new edition opened. @@ -157,35 +156,6 @@ export const hackathonRegistrationRouter = createTRPCRouter({ where: eq(members.userId, ctx.userId as string), }); - // Claiming the seat before inserting anything is what makes capacity hold - // across processes: this statement takes the hackathon row's exclusive lock, - // so a registration racing for the same last seat blocks here and re-runs - // `+ 1` against the count we wrote rather than its own snapshot. Reading the - // row back in the same transaction gives the seat this registration actually - // holds, and going over the limit rolls the claim back. It also keeps - // admin.ts's recount honest — that path locks the same row first. - await tx - .update(hackathons) - .set({ - currentParticipants: sql`${hackathons.currentParticipants} + 1`, - }) - .where(eq(hackathons.id, input.hackathonId)); - - const claimed = await tx.query.hackathons.findFirst({ - where: eq(hackathons.id, input.hackathonId), - columns: { currentParticipants: true, maxParticipants: true }, - }); - - if ( - claimed?.maxParticipants && - claimed.currentParticipants > claimed.maxParticipants - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This hackathon is full", - }); - } - const [participant] = await tx .insert(hackathonParticipants) .values({ @@ -260,6 +230,103 @@ export const hackathonRegistrationRouter = createTRPCRouter({ }), + // Takes back your own registration. There was no way out at all, so an + // accepted no-show kept a seat for good. Refused once you are part of the + // event — on a team, checked in, or with a project — since undoing any of + // those affects other people and is an organiser's call. + withdrawRegistration: protectedProcedure + .input(z.object({ hackathonId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const participant = await db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + eq(hackathonParticipants.userId, ctx.userId as string), + ), + columns: { id: true, teamId: true, registrationStatus: true }, + }); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "You are not registered for this hackathon.", + }); + } + + // A finished event's registrations are its record of who took part. + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { status: true }, + }); + if ( + hackathon?.status === "completed" || + hackathon?.status === "cancelled" + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This hackathon is over, so registrations can't be withdrawn.", + }); + } + + if (participant.registrationStatus === "checked_in") { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "You have already checked in. Ask an organiser if you need to leave the event.", + }); + } + if (participant.teamId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Leave your team first, then withdraw.", + }); + } + + const project = await db.query.hackathonProjects.findFirst({ + where: and( + eq(hackathonProjects.hackathonId, input.hackathonId), + eq(hackathonProjects.submittedById, participant.id), + ), + columns: { id: true }, + }); + if (project) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Withdraw your project first, then your registration.", + }); + } + + // The checks above are a read; a badge scan or a team join can land + // before this. Repeating them here deletes nothing in that case. + const removed = await db + .delete(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.id, participant.id), + ne(hackathonParticipants.registrationStatus, "checked_in"), + isNull(hackathonParticipants.teamId), + ), + ) + .returning({ id: hackathonParticipants.id }); + if (removed.length === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Your registration changed just now. Refresh the page and try again.", + }); + } + + // An accepted withdrawal frees a seat for the next wave. + await syncCurrentParticipants(db, input.hackathonId); + + evictParticipantCaches(ctx.cache, input.hackathonId, [ + ctx.userId as string, + ]); + + return { success: true }; + }), + myRegistrations: protectedProcedure.query(async ({ ctx }) => { const cacheKey = `hackathon:registrations:${ctx.userId}`; const cached = ctx.cache.get(cacheKey); diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index 17f228d9..4edc8322 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -338,29 +338,42 @@ export const judgeAdminRouter = createTRPCRouter({ return allJudges; }), + // Adds a judge directly — a sponsor or a walk-in — rather than waiting for + // them to apply. By email, because that is what an organiser has; the + // person needs an account, which signing in once creates. create: isAdmin .input( - z.object({ - userId: z.string().min(1).max(255), - hackathonId: z.string().uuid(), - name: z.string().max(255).optional(), - }), + z + .object({ + userId: z.string().min(1).max(255).optional(), + email: z.string().trim().email().max(255).optional(), + hackathonId: z.string().uuid(), + name: z.string().max(255).optional(), + }) + .refine((input) => !!input.userId || !!input.email, { + message: "Give the judge's email address.", + path: ["email"], + }), ) .mutation(async ({ ctx, input }) => { const user = await (ctx.db as DrizzleDB).query.users.findFirst({ - where: eq(users.id, input.userId), + where: input.userId + ? eq(users.id, input.userId) + : sql`lower(${users.email}) = lower(${input.email!})`, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", - message: "User not found", + message: input.userId + ? "User not found" + : "No account uses that email. Ask them to sign in to the portal once, then add them.", }); } const existing = await (ctx.db as DrizzleDB).query.judges.findFirst({ where: and( - eq(judges.userId, input.userId), + eq(judges.userId, user.id), eq(judges.hackathonId, input.hackathonId), ), }); @@ -375,17 +388,18 @@ export const judgeAdminRouter = createTRPCRouter({ const result = await (ctx.db as DrizzleDB) .insert(judges) .values({ - userId: input.userId, + userId: user.id, hackathonId: input.hackathonId, name: input.name || user.name, + email: user.email, isActive: true, // Manually created judges are active by default }) .returning(); // Same as approval: the role gate and the sidebar both cache, so the new // judge would otherwise wait out a 5-minute TTL for the Judge tab. - ctx.cache.deletePattern(`${CacheKeys.judge(input.userId)}*`); - invalidatePortalContext(input.userId); + ctx.cache.deletePattern(`${CacheKeys.judge(user.id)}*`); + invalidatePortalContext(user.id); return result[0]; }), @@ -680,8 +694,9 @@ export const judgeAdminRouter = createTRPCRouter({ // Approve or suspend a judge. judge.register creates the row inactive and // judge.create refuses once it exists, so without this a self-registered - // judge can never be activated by any route. - setActive: isSuperAdmin + // judge can never be activated by any route. Any admin: it was super-admin + // only while every admin saw the button, so most organisers got an error. + setActive: isAdmin .input( z.object({ judgeId: z.string().uuid(), @@ -1566,6 +1581,26 @@ export const judgeAdminRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { return await (ctx.db as DrizzleDB).transaction(async (tx) => { + // Only an edition people can see and that is not over takes judges. Any + // uuid used to pass, and one that did not exist hit the foreign key as + // a 500. Announced counts: judges are recruited before registration. + const hackathon = await tx.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { isPublic: true, status: true }, + }); + if ( + !hackathon || + !hackathon.isPublic || + !["announced", "open", "closed", "in_progress"].includes( + hackathon.status, + ) + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That hackathon is not taking judge applications.", + }); + } + // Check if user is registered as a participant for this hackathon const participant = await tx.query.hackathonParticipants.findFirst({ where: and( diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index eba687b2..c7229631 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -341,6 +341,25 @@ export const judgePortalRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + // Opening judging with nothing queued sent every judge straight to "All + // done, 0 of 0". Preparing (promote submissions, assign judges) is a + // separate step on the Judging page, so say so rather than open empty. + if (input.active) { + const [queued] = await (ctx.db as DrizzleDB) + .select({ n: sql`count(*)::int` }) + .from(judgeQueue) + // Any row, scored or not: reopening judging so a judge can fix a + // score, after every slot is done, must still be allowed. + .where(eq(judgeQueue.hackathonId, input.hackathonId)); + if ((queued?.n ?? 0) === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "No judge has a table to score yet. Prepare judging first: promote submissions and assign judges on the Judging page.", + }); + } + } + const [updated] = await (ctx.db as DrizzleDB) .update(hackathons) .set({ judgingActive: input.active, updatedAt: new Date() }) diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index 37491328..ee243934 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -19,23 +19,25 @@ type Tx = Parameters[0]>[0]; const HOUR = 60 * 60 * 1000; /** All hackathon milestones, as hour offsets from the hacking start time. */ -const TEAM_WINDOW_OPEN_HOURS = 12; +const SUBMISSION_OPEN_HOURS = 12; const TEAM_WINDOW_CLOSE_HOURS = 34; const SUBMISSION_HARD_DEADLINE_HOURS = 36; /** Rosters freeze 12h before the hard submission deadline, i.e. at +24h. */ const LEAVE_LOCK_HOURS = SUBMISSION_HARD_DEADLINE_HOURS - 12; +// Teams form any time before +34h, so accepted people can find teammates +// before kickoff. Who may form one is checkAdmitted's job, not the clock's: +// this window used to open only at +12h, a third of the way into the event. export function computeTeamWindow(baseTime: Date, now: Date) { const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR); - const opensAt = at(TEAM_WINDOW_OPEN_HOURS); const closesAt = at(TEAM_WINDOW_CLOSE_HOURS); const leaveLocksAt = at(LEAVE_LOCK_HOURS); - const isOpen = now >= opensAt && now <= closesAt; + const isOpen = now <= closesAt; return { - opensAt, + opensAt: null, closesAt, leaveLocksAt, isOpen, @@ -54,7 +56,7 @@ export function computeTeamWindow(baseTime: Date, now: Date) { export function computeSubmissionWindow(baseTime: Date, now: Date) { const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR); - const opensAt = at(TEAM_WINDOW_OPEN_HOURS); + const opensAt = at(SUBMISSION_OPEN_HOURS); /** After this, an existing submission is frozen — new ones still land. */ const editsCloseAt = at(TEAM_WINDOW_CLOSE_HOURS); const closesAt = at(SUBMISSION_HARD_DEADLINE_HOURS); @@ -115,13 +117,6 @@ async function checkTeamEditWindow(db: DrizzleDB, hackathonId: string) { const window = await loadTeamWindow(db, hackathonId); const now = new Date(); - if (now < window.opensAt) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Team creation and editing is not open yet. It starts 12 hours after the hacking begins.", - }); - } if (now > window.closesAt) { throw new TRPCError({ code: "FORBIDDEN", @@ -1142,8 +1137,19 @@ export const teamRouter = createTRPCRouter({ // belongs to the captain rather than the caller. columns: { submittedById: false }, }); + if (!project) return null; + + // Judges are sent to a table number; the team is the one party that was + // never told it. Null until the project is promoted to judging. + const judging = await db.query.judgingProjects.findFirst({ + where: and( + eq(judgingProjects.sourceProjectId, project.id), + isNull(judgingProjects.withdrawnAt), + ), + columns: { tableNumber: true }, + }); - return project ?? null; + return { ...project, tableNumber: judging?.tableNumber ?? null }; }), // Every project the caller owns, across hackathons. Reading off the team diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx index 91df5cd3..e983bf30 100644 --- a/sites/hacklytics2027/app/page.tsx +++ b/sites/hacklytics2027/app/page.tsx @@ -3,46 +3,12 @@ import React, { useState, useEffect, useSyncExternalStore } from "react"; import HomeSections from "@/components/HomeSections"; import PixelGarden, { PixelGround } from "@/components/pixel/PixelGarden"; import PixelSprite from "@/components/pixel/PixelSprite"; -import { BLOOM, DAISY, SPROUT, TULIP } from "@/components/pixel/sprites"; +import { DAISY, SPROUT, TULIP } from "@/components/pixel/sprites"; import { INTEREST_HINT, INTEREST_URL } from "@/lib/links"; -// ─── Elegant Floral Background ───────────────────────────────────────────── +// ─── Background: two soft color fields ──────────────────────────────────── const FloralBackground = () => (
- {/* Subtle grid base */} -
- - {/* Center digital bloom (rotating lotus). - No backdrop-blur on the petals: they sit over a near-black backdrop, so - the blur produced nothing visible while forcing 20 full-screen readbacks - per frame while rotating. Gradients alone look identical here. */} -
- {Array.from({ length: 12 }).map((_, i) => ( -
- ))} -
- - {/* Secondary Bloom */} -
- {Array.from({ length: 8 }).map((_, i) => ( -
- ))} -
- {/* Ambient light fields — static radial gradients instead of pulsing blur() layers, which re-rasterized a 50vw surface every frame. */}
= ({ targetDate }) => { const units = [ { label: "Days", value: timeLeft?.days, color: "text-white" }, - { label: "Hours", value: timeLeft?.hours, color: "text-white/80" }, - { label: "Minutes", value: timeLeft?.minutes, color: "text-white/60" }, - { label: "Seconds", value: timeLeft?.seconds, color: "text-white/40" }, + { label: "Hours", value: timeLeft?.hours, color: "text-white" }, + { label: "Minutes", value: timeLeft?.minutes, color: "text-white" }, + { label: "Seconds", value: timeLeft?.seconds, color: "text-bloom-cyan" }, ]; return (
{units.map(({ label, value, color }) => (
- + {mounted ? fmt(value) : "00"} - + {label}
@@ -109,7 +75,7 @@ const Countdown: React.FC<{ targetDate: Date }> = ({ targetDate }) => { // ─── Page ───────────────────────────────────────────────────────────────── export default function HomePage() { return ( -
+
@@ -117,9 +83,6 @@ export default function HomePage() { {/* ── HERO ── */}
- {/* Synthwave horizon grid */} - - {/* Required for the spin animation if not in tailwind config */} + {/* Hero entrance animation, not in the tailwind config */}