From 211ed5154b17ad6b4d66e84313dd9b8dd54f260f Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 08:46:32 -0700 Subject: [PATCH 1/7] Add user_hackathons table for per-user tracker state The tracker has only ever lived in localStorage, so a user's pipeline was tied to one browser and a win could not be recorded anywhere durable. #226 needs somewhere to record wins, and both the pipeline and the win belong to the same (user, hackathon) pair, so this models them as one owner-scoped row. Reads and writes are the owner's only, enforced by RLS against the Clerk `sub` in the JWT. `user_id` defaults from that claim rather than being accepted from the client, so a caller cannot create a row it does not own. No foreign key to hackathons on purpose: that table is an hourly mirror, while the app renders from listings.json directly, so an FK would reject saves for listings newer than the last sync. Co-authored-by: Cursor --- .../20260725154500_user_hackathons.sql | 80 +++++++++++++++++++ supabase/migrations/README.md | 22 +++++ web/db/schema.ts | 27 +++++++ 3 files changed, 129 insertions(+) create mode 100644 supabase/migrations/20260725154500_user_hackathons.sql diff --git a/supabase/migrations/20260725154500_user_hackathons.sql b/supabase/migrations/20260725154500_user_hackathons.sql new file mode 100644 index 0000000..cf62223 --- /dev/null +++ b/supabase/migrations/20260725154500_user_hackathons.sql @@ -0,0 +1,80 @@ +-- supabase/migrations/20260725154500_user_hackathons.sql +-- NOTE: not yet applied. Every other file here is named after the version +-- `apply_migration` recorded, per README. Rename this one to the version that +-- call reports before treating the two lists as aligned. +-- +-- The tracker's per-user pipeline, moved off localStorage. One row per +-- (user, hackathon): which stage it sits in, and whether the user won it (#226). +-- +-- `user_id` is the Clerk `sub`, matching the `submitted_by` convention on +-- public.hackathons. It defaults from the JWT rather than being sent by the +-- client, so a caller cannot write a row owned by someone else even before the +-- policies below are consulted. +-- +-- Deliberately NO foreign key to public.hackathons. That table is a mirror the +-- hourly sync can leave up to an hour behind .github/scripts/listings.json, +-- which is what the app actually renders from — an FK would reject a save for +-- any listing added since the last sync. The id is validated in the app layer +-- against the live listing set instead. + +create table if not exists public.user_hackathons ( + user_id text not null default auth.jwt() ->> 'sub', + hackathon_id uuid not null, + stage text not null default 'interested', + is_win boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (user_id, hackathon_id), + constraint user_hackathons_stage_check + check (stage in ('interested', 'applied', 'accepted', 'going')) +); + +-- `origin` on public.hackathons is text + check for the same reason: adding a +-- stage means dropping and recreating one constraint, not altering a pg enum. + +comment on table public.user_hackathons is + 'Per-user hackathon tracker: pipeline stage and win flag. user_id is the Clerk sub.'; + +alter table public.user_hackathons enable row level security; + +-- Reads and writes are the owner's only. `(select auth.jwt())` is wrapped so +-- Postgres evaluates the claim once per statement instead of once per row — +-- the unwrapped form is what Supabase's performance advisor flags. + +drop policy if exists "read own tracker" on public.user_hackathons; + +create policy "read own tracker" + on public.user_hackathons for select + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "insert own tracker" on public.user_hackathons; + +create policy "insert own tracker" + on public.user_hackathons for insert + to authenticated + with check ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "update own tracker" on public.user_hackathons; + +create policy "update own tracker" + on public.user_hackathons for update + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id) + with check ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "delete own tracker" on public.user_hackathons; + +create policy "delete own tracker" + on public.user_hackathons for delete + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id); + +-- Explicit grants, matching 20260722190741: Supabase's stock bootstrap is not +-- relied on, so a replay onto a fresh database produces a usable table. +-- `anon` gets nothing — a tracker has no public read. +grant select, insert, update, delete on public.user_hackathons to authenticated; +grant all on public.user_hackathons to service_role; + +-- Listing a user's tracker is the only read pattern; the primary key already +-- serves it (user_id leads), so no extra index is created here. diff --git a/supabase/migrations/README.md b/supabase/migrations/README.md index 1a625f7..800c82d 100644 --- a/supabase/migrations/README.md +++ b/supabase/migrations/README.md @@ -28,6 +28,18 @@ top saying so and saying how: | `20260722145817_rls_policies.sql` | executable SQL — gained four `drop policy if exists` lines | | `20260722144205_add_deck_columns.sql` | comments only — a stale claim about enforcement, corrected | +One file is **committed but not yet applied**, so for now `ls` here returns one +more entry than `list_migrations` does: + +| file | state | +| --- | --- | +| `20260725154500_user_hackathons.sql` | written, never sent to `apply_migration` | + +Its timestamp is therefore a placeholder rather than a recorded version. Send it +through `apply_migration`, rename the file to the version that call reports, then +delete this section — until that happens the two lists do not align, and +`supabase db push` would try to replay it. + The three SQL divergences all exist so the chain replays cleanly onto a fresh database. That makes the files the runnable artefact and the recorded statements the historical record; they are not interchangeable, and where they disagree the @@ -54,6 +66,16 @@ names, so renaming them is a breaking change for no benefit. **`host` is `company_name` renamed**, done by `build_row` in that same script. +**`user_hackathons` has no foreign key to `hackathons`.** It is the per-user +tracker (stage + win flag) added for #226, keyed on the Clerk `sub` like +`submitted_by`. An FK would look obvious and be wrong: this table's `hackathon_id` +comes from `listings.json`, which the app renders from directly, while +`hackathons` trails it by up to an hour — so a user saving a listing added since +the last sync would hit a constraint violation for a listing that plainly exists +on screen. The id is checked against the live listing set in the app layer +instead, and `user_id` defaults from `auth.jwt()` rather than being sent by the +client, so the owner cannot be spoofed even before the policies are consulted. + ## The two write paths `listings.json` reaches this table through `.github/workflows/sync_supabase.yml`, diff --git a/web/db/schema.ts b/web/db/schema.ts index e5ad954..109cb40 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -4,7 +4,9 @@ import { doublePrecision, integer, pgTable, + primaryKey, text, + timestamp, uuid, } from "drizzle-orm/pg-core"; @@ -30,3 +32,28 @@ export const hackathons = pgTable("hackathons", { lng: doublePrecision("lng"), geoStatus: text("geo_status"), }); + +/** + * Per-user tracker rows: which stage a hackathon sits in for one user, and + * whether they won it (#226). `userId` is the Clerk `sub`, matching the + * `submittedBy` convention above. + * + * There is intentionally no foreign key to `hackathons` — that table is an + * hourly mirror of `listings.json`, and the app renders from the JSON, so an FK + * would reject saves for listings newer than the last sync. The authoritative + * definition (defaults, RLS, grants) is + * `supabase/migrations/20260725154500_user_hackathons.sql`; this mirrors it for + * Drizzle's benefit and does not create the policies. + */ +export const userHackathons = pgTable( + "user_hackathons", + { + userId: text("user_id").notNull(), + hackathonId: uuid("hackathon_id").notNull(), + stage: text("stage").notNull().default("interested"), + isWin: boolean("is_win").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [primaryKey({ columns: [table.userId, table.hackathonId] })], +); From a7a8bf3f7af335c7843dfa4ca541717721933622 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 08:52:40 -0700 Subject: [PATCH 2/7] Add /api/tracker for reading and writing a user's pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gives the tracker a server to talk to. Four operations, all scoped to the Clerk session's user id — never to a user id taken from a request body — plus a POST that hands a browser-local tracker over additively, so signing in on a second device cannot roll a pipeline back to whatever that browser remembered. The stage list moves from components/hq/store.tsx to lib/tracker.ts so the route validates against the same vocabulary the UI renders, rather than a second copy that could drift. store.tsx re-exports it, so no call site changes. Sync is optional like Clerk and Mapbox already are: without both Supabase variables the route answers 200 with `synced: false`, which the client will read as its cue to stay on localStorage. Only a signed-out caller on a configured deployment gets a 401, because there that is a real failure. Co-authored-by: Cursor --- package-lock.json | 6 ++ web/.env.example | 8 ++ web/app/api/tracker/route.ts | 163 +++++++++++++++++++++++++++++++++++ web/components/hq/store.tsx | 39 +++------ web/lib/env.test.ts | 101 ++++++++++++++++++++++ web/lib/env.ts | 47 +++++++++- web/lib/tracker-store.ts | 145 +++++++++++++++++++++++++++++++ web/lib/tracker.test.ts | 124 ++++++++++++++++++++++++++ web/lib/tracker.ts | 131 ++++++++++++++++++++++++++++ web/package-lock.json | 95 ++++++++++++++++++++ web/package.json | 2 + 11 files changed, 828 insertions(+), 33 deletions(-) create mode 100644 package-lock.json create mode 100644 web/app/api/tracker/route.ts create mode 100644 web/lib/env.test.ts create mode 100644 web/lib/tracker-store.ts create mode 100644 web/lib/tracker.test.ts create mode 100644 web/lib/tracker.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a1d5c54 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "hackhq", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/.env.example b/web/.env.example index a99a467..17c5063 100644 --- a/web/.env.example +++ b/web/.env.example @@ -10,6 +10,14 @@ NEXT_PUBLIC_MAPBOX_TOKEN= NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= +# Supabase — optional; both are required to persist trackers and wins to an +# account instead of the browser. Server-only on purpose: the service role key +# must never carry a NEXT_PUBLIC_ prefix. Clerk must be configured too, or there +# is no user to attribute a saved hackathon to. +# Project URL and service_role key: Supabase dashboard -> Project Settings -> API. +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= + # Drizzle/Supabase Postgres — only needed when running npm run db:* scripts. # Use the direct or pooled Postgres connection string from Supabase. DATABASE_URL= diff --git a/web/app/api/tracker/route.ts b/web/app/api/tracker/route.ts new file mode 100644 index 0000000..fd3485e --- /dev/null +++ b/web/app/api/tracker/route.ts @@ -0,0 +1,163 @@ +/* --------------------------------------------------------------------------- + /api/tracker — the signed-in user's pipeline and wins (#226). + + | Method | Does | + | ------ | ---- | + | GET | list every row for the caller | + | PUT | create or update one row (stage and/or win) | + | DELETE | remove one row | + | POST | hand a browser-local tracker over, additively, on first sign-in | + + Two responses mean "carry on locally" rather than "something broke": + `{ synced: false }` with a 200, returned when Clerk or Supabase is not + configured. The tracker has always worked without a server, that stays true, + and the client treats a 200 with `synced: false` as its cue to keep using + localStorage alone. A signed-out caller, by contrast, gets a 401 — on a + configured deployment that is a real failure, not a degraded mode. +--------------------------------------------------------------------------- */ + +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import { isTrackerSyncConfigured } from "@/lib/env"; +import { isHackathonId, isStage, parseTrackerEntries } from "@/lib/tracker"; +import { + deleteTrackerRow, + importTrackerRows, + listTracker, + upsertTrackerRow, +} from "@/lib/tracker-store"; + +// Per-user data: never prerendered, never cached. +export const dynamic = "force-dynamic"; + +const NOT_SYNCED = { synced: false as const }; + +/** + * Resolve the caller, or the reason there is nothing to do. Both keys and the + * Clerk session are checked before any query, so an unconfigured deployment + * never constructs a Supabase client. + */ +async function resolveUser(): Promise< + { userId: string } | { response: NextResponse } +> { + if (!isTrackerSyncConfigured()) { + return { response: NextResponse.json(NOT_SYNCED) }; + } + const { userId } = await auth(); + if (!userId) { + return { + response: NextResponse.json({ error: "Not signed in" }, { status: 401 }), + }; + } + return { userId }; +} + +/** + * Log the real cause server-side, tell the client only that it failed. Supabase + * error messages can name columns and constraints, which is not something to + * hand back over the wire. + */ +function serverError(operation: string, error: unknown): NextResponse { + console.error(`[api/tracker] ${operation} failed:`, error); + return NextResponse.json({ error: "Tracker sync failed" }, { status: 500 }); +} + +export async function GET() { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + try { + const entries = await listTracker(resolved.userId); + return NextResponse.json({ synced: true, entries }); + } catch (error) { + return serverError("list", error); + } +} + +export async function PUT(request: Request) { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { hackathonId, stage, isWin } = (body ?? {}) as Record; + if (!isHackathonId(hackathonId)) { + return NextResponse.json({ error: "Invalid hackathonId" }, { status: 400 }); + } + if (stage !== undefined && !isStage(stage)) { + return NextResponse.json({ error: "Invalid stage" }, { status: 400 }); + } + if (isWin !== undefined && typeof isWin !== "boolean") { + return NextResponse.json({ error: "Invalid isWin" }, { status: 400 }); + } + if (stage === undefined && isWin === undefined) { + return NextResponse.json( + { error: "Provide stage, isWin, or both" }, + { status: 400 }, + ); + } + + try { + const entry = await upsertTrackerRow(resolved.userId, hackathonId, { + stage, + isWin, + }); + return NextResponse.json({ synced: true, entry }); + } catch (error) { + return serverError("upsert", error); + } +} + +export async function DELETE(request: Request) { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + const hackathonId = new URL(request.url).searchParams.get("hackathonId"); + if (!isHackathonId(hackathonId)) { + return NextResponse.json({ error: "Invalid hackathonId" }, { status: 400 }); + } + + try { + await deleteTrackerRow(resolved.userId, hackathonId); + return NextResponse.json({ synced: true }); + } catch (error) { + return serverError("delete", error); + } +} + +// A local tracker can only be as large as the listing set, but the bound is +// stated rather than assumed so an oversized body is rejected before it becomes +// one enormous upsert. +const MAX_IMPORT_ENTRIES = 500; + +export async function POST(request: Request) { + const resolved = await resolveUser(); + if ("response" in resolved) return resolved.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const entries = parseTrackerEntries((body as { entries?: unknown })?.entries); + if (entries.length > MAX_IMPORT_ENTRIES) { + return NextResponse.json({ error: "Too many entries" }, { status: 413 }); + } + + try { + await importTrackerRows(resolved.userId, entries); + // Return the merged result so the client adopts the server's view in one + // round trip, including any row the import deliberately left alone. + const merged = await listTracker(resolved.userId); + return NextResponse.json({ synced: true, entries: merged }); + } catch (error) { + return serverError("import", error); + } +} diff --git a/web/components/hq/store.tsx b/web/components/hq/store.tsx index 6bd3b0e..6b783af 100644 --- a/web/components/hq/store.tsx +++ b/web/components/hq/store.tsx @@ -9,35 +9,16 @@ import { useState, } from "react"; import type { Hackathon } from "@/lib/types-hq"; - -export type Stage = "interested" | "applied" | "accepted" | "going"; - -export const STAGES: { id: Stage; label: string; color: string }[] = [ - { id: "interested", label: "Interested", color: "#17b26a" }, - { id: "applied", label: "Applied", color: "#f5a623" }, - { id: "accepted", label: "Accepted", color: "#3b6bf0" }, - { id: "going", label: "Going", color: "#ed5b29" }, -]; - -type TrackerMap = Record; - -const STAGE_IDS = new Set(STAGES.map((s) => s.id)); - -/** - * Coerce an untrusted parsed localStorage value into a clean TrackerMap: - * keep only string ids mapped to a known Stage, drop everything else. Guards - * against a valid-JSON-but-wrong-shape payload being cast blindly. - */ -function sanitizeTrackerMap(value: unknown): TrackerMap { - if (!value || typeof value !== "object") return {}; - const out: TrackerMap = {}; - for (const [id, stage] of Object.entries(value as Record)) { - if (typeof stage === "string" && STAGE_IDS.has(stage)) { - out[id] = stage as Stage; - } - } - return out; -} +import { + sanitizeTrackerMap, + type Stage, + type TrackerMap, +} from "@/lib/tracker"; + +// The stage vocabulary lives in lib/tracker.ts so /api/tracker can validate +// against the same list. Re-exported here because this is where the app has +// always imported it from. +export { STAGES, type Stage } from "@/lib/tracker"; type TrackerContextValue = { tracked: TrackerMap; diff --git a/web/lib/env.test.ts b/web/lib/env.test.ts new file mode 100644 index 0000000..09bd113 --- /dev/null +++ b/web/lib/env.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const KEYS = [ + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "CLERK_SECRET_KEY", + "SUPABASE_URL", + "SUPABASE_SERVICE_ROLE_KEY", +] as const; + +/** + * validateEnv() warns only once per module instance, so each case re-imports a + * fresh copy rather than sharing one. + */ +async function loadEnv(set: Partial>) { + for (const key of KEYS) delete process.env[key]; + Object.assign(process.env, set); + vi.resetModules(); + return import("./env"); +} + +afterEach(() => { + for (const key of KEYS) delete process.env[key]; + vi.restoreAllMocks(); +}); + +const CLERK = { + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_test", + CLERK_SECRET_KEY: "sk_test", +}; +const SUPABASE = { + SUPABASE_URL: "https://project.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "service-role", +}; + +describe("isTrackerSyncConfigured", () => { + it("is on only when Clerk and Supabase are both fully configured", async () => { + const { isTrackerSyncConfigured } = await loadEnv({ ...CLERK, ...SUPABASE }); + expect(isTrackerSyncConfigured()).toBe(true); + }); + + it("is off without Supabase, so the tracker stays browser-local", async () => { + const { isTrackerSyncConfigured } = await loadEnv(CLERK); + expect(isTrackerSyncConfigured()).toBe(false); + }); + + it("is off without Clerk — there would be no user to attribute a row to", async () => { + const { isTrackerSyncConfigured } = await loadEnv(SUPABASE); + expect(isTrackerSyncConfigured()).toBe(false); + }); + + it("is off when only one Supabase value is set", async () => { + const { isTrackerSyncConfigured } = await loadEnv({ + ...CLERK, + SUPABASE_URL: SUPABASE.SUPABASE_URL, + }); + expect(isTrackerSyncConfigured()).toBe(false); + }); +}); + +describe("validateEnv", () => { + it("reports a half-configured Supabase rather than failing quietly", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { validateEnv } = await loadEnv({ + ...CLERK, + SUPABASE_URL: SUPABASE.SUPABASE_URL, + }); + + expect(validateEnv().trackerSync).toBe("partial"); + expect(warn.mock.calls.flat().join(" ")).toContain( + "SUPABASE_SERVICE_ROLE_KEY", + ); + }); + + it("warns when Supabase is ready but Clerk is not, since sync still cannot run", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { validateEnv } = await loadEnv(SUPABASE); + const report = validateEnv(); + + expect(report.trackerSync).toBe("enabled"); + expect(report.clerk).toBe("disabled"); + expect(warn.mock.calls.flat().join(" ")).toContain( + "Supabase is configured but Clerk is not", + ); + }); + + it("stays quiet about auth and sync when everything is set", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { validateEnv } = await loadEnv({ + ...CLERK, + ...SUPABASE, + NEXT_PUBLIC_MAPBOX_TOKEN: "pk.mapbox", + } as Record); + + expect(validateEnv()).toEqual({ + mapbox: true, + clerk: "enabled", + trackerSync: "enabled", + }); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/web/lib/env.ts b/web/lib/env.ts index a13a494..2d2360f 100644 --- a/web/lib/env.ts +++ b/web/lib/env.ts @@ -4,9 +4,12 @@ // so we warn rather than throw. But a *partial* Clerk config (one key set, the // other missing) is a real misconfiguration worth flagging loudly at startup. +export type Availability = "enabled" | "disabled" | "partial"; + export type EnvReport = { mapbox: boolean; - clerk: "enabled" | "disabled" | "partial"; + clerk: Availability; + trackerSync: Availability; }; let reported = false; @@ -21,12 +24,33 @@ export function isClerkConfigured(): boolean { ); } +// Persisting a tracker needs somewhere to put it *and* someone to attribute it +// to, so this is deliberately Clerk-inclusive: with Supabase configured but +// sign-in switched off there is no user id, and /api/tracker would have nothing +// to scope a row by. Both variables are server-only — no NEXT_PUBLIC_ prefix — +// so the service role key never reaches the browser bundle. +export function isTrackerSyncConfigured(): boolean { + return Boolean( + isClerkConfigured() && + process.env.SUPABASE_URL && + process.env.SUPABASE_SERVICE_ROLE_KEY, + ); +} + +function availability(...flags: boolean[]): Availability { + if (flags.every(Boolean)) return "enabled"; + if (flags.every((f) => !f)) return "disabled"; + return "partial"; +} + export function validateEnv(): EnvReport { const mapbox = Boolean(process.env.NEXT_PUBLIC_MAPBOX_TOKEN); const pub = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY); const secret = Boolean(process.env.CLERK_SECRET_KEY); - const clerk: EnvReport["clerk"] = - pub && secret ? "enabled" : !pub && !secret ? "disabled" : "partial"; + const supabaseUrl = Boolean(process.env.SUPABASE_URL); + const supabaseKey = Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY); + const clerk = availability(pub, secret); + const trackerSync = availability(supabaseUrl, supabaseKey); if (!reported) { reported = true; @@ -41,6 +65,21 @@ export function validateEnv(): EnvReport { "and CLERK_SECRET_KEY, or neither. Sign-in stays disabled until both exist.", ); } + if (trackerSync === "partial") { + console.warn( + "[env] Supabase is half-configured: set BOTH SUPABASE_URL and " + + "SUPABASE_SERVICE_ROLE_KEY, or neither. Trackers stay browser-local until both exist.", + ); + } + // Not "partial" in the half-configured sense — both Supabase values are + // present and valid — but the result is the same dead end, so it is worth + // the same warning rather than a silent fallback to localStorage. + if (trackerSync === "enabled" && clerk !== "enabled") { + console.warn( + "[env] Supabase is configured but Clerk is not, so tracker sync stays off: " + + "there is no signed-in user to attribute a saved hackathon to.", + ); + } } - return { mapbox, clerk }; + return { mapbox, clerk, trackerSync }; } diff --git a/web/lib/tracker-store.ts b/web/lib/tracker-store.ts new file mode 100644 index 0000000..4419f69 --- /dev/null +++ b/web/lib/tracker-store.ts @@ -0,0 +1,145 @@ +/* --------------------------------------------------------------------------- + Server-side reads and writes for public.user_hackathons. + + This is the app's first runtime database access — every other page reads + listings.json off disk — so it is deliberately narrow: four operations, all + scoped to one user, and no query in here takes a user id from a caller's + request body. The id always comes from the Clerk session, resolved in the + route handler. + + ## Why the service role, and what still guards the rows + + The client is built with the service role key, which bypasses RLS. Ownership + is therefore enforced here, by the `.eq("user_id", userId)` on every read, + update and delete and by writing `user_id` explicitly on every insert. + + The RLS policies in the migration are not redundant. They mean `anon` and + `authenticated` cannot touch this table at all, so nothing reachable with a + publishable key can read one user's tracker — and they are what a future + browser-direct path would run under. The trade-off of the service role is + that a missing filter in this file would not be caught by the database, which + is why the surface is kept this small and why `userId` is a required + parameter rather than an option on every function below. + + Upgrade path: Clerk's native Supabase integration lets the browser's own + session token satisfy those policies directly, moving enforcement back into + Postgres. That needs a trust relationship configured in both dashboards, so + it is a follow-up rather than a prerequisite for shipping this. +--------------------------------------------------------------------------- */ + +import "server-only"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import type { Stage, TrackerEntry } from "./tracker"; +import { parseTrackerEntries } from "./tracker"; + +const TABLE = "user_hackathons"; + +let cached: SupabaseClient | null = null; + +function client(): SupabaseClient { + if (cached) return cached; + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) { + throw new Error("Supabase is not configured for tracker sync"); + } + // No session persistence or refresh: this runs per request on the server and + // authenticates with a static key, so the auth machinery has nothing to do. + cached = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + return cached; +} + +/** Every row belonging to one user, ready for the UI to split into its maps. */ +export async function listTracker(userId: string): Promise { + const { data, error } = await client() + .from(TABLE) + .select("hackathon_id, stage, is_win") + .eq("user_id", userId); + if (error) throw new Error(error.message); + + return parseTrackerEntries( + (data ?? []).map((row) => ({ + hackathonId: row.hackathon_id, + stage: row.stage, + isWin: row.is_win, + })), + ); +} + +/** + * Create or update one row. Partial by design: moving a hackathon between + * stages must not clear its win, and recording a win must not reset its stage, + * so an omitted field falls back to the stored value (or the column default on + * a first insert) instead of being written as false. + */ +export async function upsertTrackerRow( + userId: string, + hackathonId: string, + patch: { stage?: Stage; isWin?: boolean }, +): Promise { + const { data: existing, error: readError } = await client() + .from(TABLE) + .select("stage, is_win") + .eq("user_id", userId) + .eq("hackathon_id", hackathonId) + .maybeSingle(); + if (readError) throw new Error(readError.message); + + const stage: Stage = patch.stage ?? (existing?.stage as Stage) ?? "interested"; + const isWin = patch.isWin ?? existing?.is_win === true; + + const { error } = await client() + .from(TABLE) + .upsert( + { + user_id: userId, + hackathon_id: hackathonId, + stage, + is_win: isWin, + updated_at: new Date().toISOString(), + }, + { onConflict: "user_id,hackathon_id" }, + ); + if (error) throw new Error(error.message); + + return { hackathonId, stage, isWin }; +} + +export async function deleteTrackerRow( + userId: string, + hackathonId: string, +): Promise { + const { error } = await client() + .from(TABLE) + .delete() + .eq("user_id", userId) + .eq("hackathon_id", hackathonId); + if (error) throw new Error(error.message); +} + +/** + * Hand a browser's local tracker over on first sign-in. `ignoreDuplicates` + * makes this additive: an account that already tracks a hackathon keeps the + * stage it has on the server, so signing in on a second device cannot roll the + * pipeline back to whatever that browser happened to remember. + */ +export async function importTrackerRows( + userId: string, + entries: TrackerEntry[], +): Promise { + if (entries.length === 0) return; + const { error } = await client() + .from(TABLE) + .upsert( + entries.map((e) => ({ + user_id: userId, + hackathon_id: e.hackathonId, + stage: e.stage, + is_win: e.isWin, + })), + { onConflict: "user_id,hackathon_id", ignoreDuplicates: true }, + ); + if (error) throw new Error(error.message); +} diff --git a/web/lib/tracker.test.ts b/web/lib/tracker.test.ts new file mode 100644 index 0000000..e9a18b1 --- /dev/null +++ b/web/lib/tracker.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + isHackathonId, + isStage, + parseTrackerEntries, + sanitizeTrackerMap, + sanitizeWinMap, + splitEntries, + toTrackerEntries, +} from "./tracker"; + +const ID_A = "57177cd1-cff8-4e80-b701-6811dbcdb1a4"; +const ID_B = "4c7865aa-543e-4ac8-9f47-808da1bffddc"; + +describe("isStage", () => { + it("accepts the four pipeline stages and nothing else", () => { + expect(isStage("interested")).toBe(true); + expect(isStage("going")).toBe(true); + expect(isStage("won")).toBe(false); + expect(isStage(undefined)).toBe(false); + }); +}); + +describe("isHackathonId", () => { + it("accepts a listing UUID and rejects anything that isn't one", () => { + expect(isHackathonId(ID_A)).toBe(true); + expect(isHackathonId(ID_A.toUpperCase())).toBe(true); + expect(isHackathonId("not-a-uuid")).toBe(false); + // A SQL fragment must not reach a query as an id. + expect(isHackathonId("' or 1=1 --")).toBe(false); + expect(isHackathonId(null)).toBe(false); + }); +}); + +describe("sanitizeTrackerMap", () => { + it("keeps known stages and drops everything else", () => { + expect( + sanitizeTrackerMap({ [ID_A]: "going", [ID_B]: "nonsense", x: 3 }), + ).toEqual({ [ID_A]: "going" }); + }); + + it("returns an empty map for a non-object", () => { + expect(sanitizeTrackerMap(null)).toEqual({}); + expect(sanitizeTrackerMap("[]")).toEqual({}); + }); +}); + +describe("sanitizeWinMap", () => { + it("keeps only entries that are exactly true", () => { + expect( + sanitizeWinMap({ [ID_A]: true, [ID_B]: false, c: "true", d: 1 }), + ).toEqual({ [ID_A]: true }); + }); +}); + +describe("parseTrackerEntries", () => { + it("keeps valid rows and defaults a missing win flag to false", () => { + expect( + parseTrackerEntries([{ hackathonId: ID_A, stage: "applied" }]), + ).toEqual([{ hackathonId: ID_A, stage: "applied", isWin: false }]); + }); + + it("drops invalid rows without losing the valid ones", () => { + const entries = parseTrackerEntries([ + { hackathonId: ID_A, stage: "going", isWin: true }, + { hackathonId: "nope", stage: "going" }, + { hackathonId: ID_B, stage: "invented" }, + null, + "junk", + ]); + expect(entries).toEqual([ + { hackathonId: ID_A, stage: "going", isWin: true }, + ]); + }); + + it("keeps the first of a duplicated id, so an upsert can't conflict with itself", () => { + const entries = parseTrackerEntries([ + { hackathonId: ID_A, stage: "going" }, + { hackathonId: ID_A, stage: "interested" }, + ]); + expect(entries).toEqual([ + { hackathonId: ID_A, stage: "going", isWin: false }, + ]); + }); + + it("returns an empty list for a non-array", () => { + expect(parseTrackerEntries({ hackathonId: ID_A })).toEqual([]); + }); +}); + +describe("splitEntries", () => { + it("splits rows into the stage map and the win map", () => { + expect( + splitEntries([ + { hackathonId: ID_A, stage: "going", isWin: true }, + { hackathonId: ID_B, stage: "applied", isWin: false }, + ]), + ).toEqual({ + tracked: { [ID_A]: "going", [ID_B]: "applied" }, + wins: { [ID_A]: true }, + }); + }); +}); + +describe("toTrackerEntries", () => { + it("pairs each stage with its win flag", () => { + expect( + toTrackerEntries({ [ID_A]: "going", [ID_B]: "applied" }, { [ID_A]: true }), + ).toEqual([ + { hackathonId: ID_A, stage: "going", isWin: true }, + { hackathonId: ID_B, stage: "applied", isWin: false }, + ]); + }); + + it("drops ids that aren't UUIDs, so a hand-edited localStorage can't poison the import", () => { + expect(toTrackerEntries({ "hack-1": "going" }, {})).toEqual([]); + }); + + it("drops ids missing from the known listing set", () => { + expect( + toTrackerEntries({ [ID_A]: "going", [ID_B]: "applied" }, {}, [ID_A]), + ).toEqual([{ hackathonId: ID_A, stage: "going", isWin: false }]); + }); +}); diff --git a/web/lib/tracker.ts b/web/lib/tracker.ts new file mode 100644 index 0000000..bf3f69c --- /dev/null +++ b/web/lib/tracker.ts @@ -0,0 +1,131 @@ +/* --------------------------------------------------------------------------- + The tracker vocabulary, shared by the browser and the server. + + Stages used to be declared in components/hq/store.tsx, which carries + "use client". Now that /api/tracker validates the same values, they live here + instead: a plain module both sides can import, with no client boundary and no + second copy of the stage list to keep in step. store.tsx re-exports `Stage` + and `STAGES` so existing importers are unaffected. + + Everything here is pure, so the parsing that guards both untrusted request + bodies and untrusted localStorage is unit tested directly (tracker.test.ts). +--------------------------------------------------------------------------- */ + +export type Stage = "interested" | "applied" | "accepted" | "going"; + +export const STAGES: { id: Stage; label: string; color: string }[] = [ + { id: "interested", label: "Interested", color: "#17b26a" }, + { id: "applied", label: "Applied", color: "#f5a623" }, + { id: "accepted", label: "Accepted", color: "#3b6bf0" }, + { id: "going", label: "Going", color: "#ed5b29" }, +]; + +const STAGE_IDS = new Set(STAGES.map((s) => s.id)); + +export function isStage(value: unknown): value is Stage { + return typeof value === "string" && STAGE_IDS.has(value); +} + +/** hackathon id -> pipeline stage. */ +export type TrackerMap = Record; + +/** hackathon id -> won it. Only `true` entries are kept. */ +export type WinMap = Record; + +/** One tracker row, as it crosses the wire and as it sits in the database. */ +export type TrackerEntry = { + hackathonId: string; + stage: Stage; + isWin: boolean; +}; + +/** + * Listing ids are UUIDs, both in listings.json and in the `hackathon_id` column. + * Checked before any query so a malformed id is a 400 here rather than a + * Postgres cast error surfacing as a 500. + */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isHackathonId(value: unknown): value is string { + return typeof value === "string" && UUID_RE.test(value); +} + +/** + * Coerce an untrusted value into a clean TrackerMap: keep only string ids + * mapped to a known Stage, drop everything else. Guards against a + * valid-JSON-but-wrong-shape payload being cast blindly. + */ +export function sanitizeTrackerMap(value: unknown): TrackerMap { + if (!value || typeof value !== "object") return {}; + const out: TrackerMap = {}; + for (const [id, stage] of Object.entries(value as Record)) { + if (isStage(stage)) out[id] = stage; + } + return out; +} + +/** The same guard for the win map. Anything not exactly `true` is dropped. */ +export function sanitizeWinMap(value: unknown): WinMap { + if (!value || typeof value !== "object") return {}; + const out: WinMap = {}; + for (const [id, won] of Object.entries(value as Record)) { + if (won === true) out[id] = true; + } + return out; +} + +/** + * Parse a list of entries from an untrusted source — an API response, or the + * import payload a browser sends when handing its local tracker over. Rows that + * don't validate are dropped rather than failing the batch: one bad id should + * not cost a user the rest of their pipeline. + */ +export function parseTrackerEntries(value: unknown): TrackerEntry[] { + if (!Array.isArray(value)) return []; + const out: TrackerEntry[] = []; + const seen = new Set(); + for (const row of value) { + if (!row || typeof row !== "object") continue; + const { hackathonId, stage, isWin } = row as Record; + if (!isHackathonId(hackathonId) || !isStage(stage)) continue; + if (seen.has(hackathonId)) continue; + seen.add(hackathonId); + out.push({ hackathonId, stage, isWin: isWin === true }); + } + return out; +} + +/** Entries -> the two maps the UI renders from. */ +export function splitEntries(entries: TrackerEntry[]): { + tracked: TrackerMap; + wins: WinMap; +} { + const tracked: TrackerMap = {}; + const wins: WinMap = {}; + for (const e of entries) { + tracked[e.hackathonId] = e.stage; + if (e.isWin) wins[e.hackathonId] = true; + } + return { tracked, wins }; +} + +/** + * The two maps -> entries, for the one-time handover of a browser-local tracker. + * Only ids the caller can still see are sent; a stale id from a delisted + * hackathon is not worth carrying into the database. + */ +export function toTrackerEntries( + tracked: TrackerMap, + wins: WinMap, + knownIds?: Iterable, +): TrackerEntry[] { + const known = knownIds ? new Set(knownIds) : null; + return Object.entries(tracked) + .filter(([id]) => isHackathonId(id) && (!known || known.has(id))) + .map(([hackathonId, stage]) => ({ + hackathonId, + stage, + isWin: wins[hackathonId] === true, + })); +} diff --git a/web/package-lock.json b/web/package-lock.json index 92575ee..6c0a7b1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@clerk/nextjs": "^7.5.12", + "@supabase/supabase-js": "^2.110.8", "drizzle-orm": "^0.45.2", "framer-motion": "^11.18.2", "mapbox-gl": "^3.25.0", @@ -16,6 +17,7 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "server-only": "^0.0.1", "sharp": "^0.35.0" }, "devDependencies": { @@ -2181,6 +2183,90 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz", + "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz", + "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz", + "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz", + "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz", + "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz", + "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.8", + "@supabase/functions-js": "2.110.8", + "@supabase/postgrest-js": "2.110.8", + "@supabase/realtime-js": "2.110.8", + "@supabase/storage-js": "2.110.8" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5345,6 +5431,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/web/package.json b/web/package.json index d4e970d..22d7388 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@clerk/nextjs": "^7.5.12", + "@supabase/supabase-js": "^2.110.8", "drizzle-orm": "^0.45.2", "framer-motion": "^11.18.2", "mapbox-gl": "^3.25.0", @@ -24,6 +25,7 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "server-only": "^0.0.1", "sharp": "^0.35.0" }, "devDependencies": { From b7f323bc67a1d00e1af1f8f779a6663c99b3309e Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 09:04:54 -0700 Subject: [PATCH 3/7] Remove a stray root package-lock.json An 85-byte stub created by an `npm install` that ran from the repo root instead of web/. There is no package.json there, so it described nothing and only invited npm to treat the root as a project. Co-authored-by: Cursor --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index a1d5c54..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "hackhq", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 970075baffe5f30140acb3bbaf34dc18199e81ac Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 09:07:28 -0700 Subject: [PATCH 4/7] Sync the tracker to the signed-in user's account, and record wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider now asks /api/tracker on mount whether this session has an account to save to, and adopts its rows when it does. It asks the route rather than reading Clerk hooks because it also mounts where no is above it, and those hooks throw there. A browser's existing tracker is handed over once, on the first synced visit, guarded by a localStorage flag — the import is additive, so without the flag a later visit would resurrect rows the user had deleted from their account. Wins live in a second map rather than as a fifth stage, so the pipeline and the passport keep working off the stage list unchanged. Claiming a win also moves the hackathon to Going: a trophy on something still sitting in Interested would not mean anything. Writes are optimistic and revert when the request fails. Showing a save that isn't there is worse than a flicker, because the next visit would silently replace it with the server's version. Co-authored-by: Cursor --- web/components/hq/store.tsx | 256 ++++++++++++++++++++++++++++++++++-- 1 file changed, 245 insertions(+), 11 deletions(-) diff --git a/web/components/hq/store.tsx b/web/components/hq/store.tsx index 6b783af..5947b78 100644 --- a/web/components/hq/store.tsx +++ b/web/components/hq/store.tsx @@ -6,13 +6,19 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import type { Hackathon } from "@/lib/types-hq"; import { + parseTrackerEntries, sanitizeTrackerMap, + sanitizeWinMap, + splitEntries, + toTrackerEntries, type Stage, type TrackerMap, + type WinMap, } from "@/lib/tracker"; // The stage vocabulary lives in lib/tracker.ts so /api/tracker can validate @@ -22,10 +28,15 @@ export { STAGES, type Stage } from "@/lib/tracker"; type TrackerContextValue = { tracked: TrackerMap; + wins: WinMap; save: (id: string) => void; move: (id: string, stage: Stage) => void; remove: (id: string) => void; isTracked: (id: string) => boolean; + hasWin: (id: string) => boolean; + toggleWin: (id: string) => void; + /** True once this tracker is backed by the signed-in user's account. */ + synced: boolean; }; type SelectionContextValue = { @@ -39,19 +50,58 @@ const TrackerCtx = createContext(null); const SelectionCtx = createContext(null); const LS_KEY = "hackhq-tracker-v1"; +const LS_WINS_KEY = "hackhq-wins-v1"; +// Set once this browser has offered its local tracker to an account. Without it +// a later visit would re-upload rows the user has since deleted from their +// account, because the import is deliberately additive. +const LS_IMPORTED_KEY = "hackhq-tracker-imported-v1"; + +/* --------------------------------------------------------------------------- + Where the tracker lives + + localStorage first, always: it needs no account, and it is what a signed-out + visitor gets. On mount the provider then asks /api/tracker whether this + session has somewhere better to put it. Three answers, all of them fine: + + 200 { synced: false } sync isn't configured — stay local + 401 signed out — stay local + 200 { synced: true } adopt the account's rows as the truth + + The provider asks the route rather than reading Clerk hooks, because it also + mounts on deployments with no above it, where those hooks + throw. Only the third answer switches `synced` on, and only then does a + change get written through to the server. +--------------------------------------------------------------------------- */ export function HQProvider({ children }: { children: React.ReactNode }) { const [tracked, setTracked] = useState({}); + const [wins, setWins] = useState({}); const [selected, setSelected] = useState(null); const [hydrated, setHydrated] = useState(false); + const [synced, setSynced] = useState(false); + + // Mirrors of the state above, for the async paths: a rollback needs the value + // from before its request, and the sync effect must not re-run on every edit. + // Kept up to date in an effect, and declared before the effects that read + // them so they are current by the time those run. + const trackedRef = useRef(tracked); + const winsRef = useRef(wins); + const syncedRef = useRef(false); + + useEffect(() => { + trackedRef.current = tracked; + winsRef.current = wins; + }, [tracked, wins]); useEffect(() => { try { const raw = localStorage.getItem(LS_KEY); + const rawWins = localStorage.getItem(LS_WINS_KEY); // Deliberate post-mount hydration (localStorage is unavailable during // SSR); validate the shape rather than trusting any valid JSON. // eslint-disable-next-line react-hooks/set-state-in-effect if (raw) setTracked(sanitizeTrackerMap(JSON.parse(raw))); + if (rawWins) setWins(sanitizeWinMap(JSON.parse(rawWins))); } catch { /* first visit / corrupted - start fresh */ } @@ -62,34 +112,218 @@ export function HQProvider({ children }: { children: React.ReactNode }) { if (!hydrated) return; try { localStorage.setItem(LS_KEY, JSON.stringify(tracked)); + localStorage.setItem(LS_WINS_KEY, JSON.stringify(wins)); } catch { /* private mode - tracker just won't persist */ } - }, [tracked, hydrated]); + }, [tracked, wins, hydrated]); - const save = useCallback( - (id: string) => - setTracked((t) => (t[id] ? t : { ...t, [id]: "interested" })), + // Adopt the account's tracker, handing this browser's over first if it has one + // and hasn't already. Runs after hydration so there is something to hand over. + useEffect(() => { + if (!hydrated) return; + let cancelled = false; + + (async () => { + try { + const res = await fetch("/api/tracker"); + if (!res.ok) return; + const body = await res.json(); + if (body?.synced !== true) return; + + let entries = parseTrackerEntries(body.entries); + + if (!localStorage.getItem(LS_IMPORTED_KEY)) { + const local = toTrackerEntries(trackedRef.current, winsRef.current); + if (local.length > 0) { + const imported = await fetch("/api/tracker", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entries: local }), + }); + if (imported.ok) { + const merged = await imported.json(); + if (merged?.synced === true) { + entries = parseTrackerEntries(merged.entries); + } + } + } + localStorage.setItem(LS_IMPORTED_KEY, "1"); + } + + if (cancelled) return; + const next = splitEntries(entries); + setTracked(next.tracked); + setWins(next.wins); + syncedRef.current = true; + setSynced(true); + } catch { + /* offline or blocked - the local tracker carries on */ + } + })(); + + return () => { + cancelled = true; + }; + }, [hydrated]); + + /** + * Write one row through to the account, reverting the optimistic edit if it + * doesn't land. Reverting is the point: leaving the change on screen would + * show a save that isn't there, and the next visit would quietly replace it + * with the server's version. + */ + const push = useCallback( + async (request: () => Promise, rollback: () => void) => { + if (!syncedRef.current) return; + try { + const res = await request(); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (error) { + console.warn("[tracker] could not save to your account:", error); + rollback(); + } + }, [], ); - const move = useCallback( - (id: string, stage: Stage) => setTracked((t) => ({ ...t, [id]: stage })), + + const putRow = useCallback( + (body: { hackathonId: string; stage?: Stage; isWin?: boolean }) => + fetch("/api/tracker", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), [], ); + + /** Restore one id to the stage and win it held before an optimistic edit. */ + const restore = useCallback((id: string, stage?: Stage, won?: boolean) => { + setTracked((t) => { + if (!stage) { + const rest = { ...t }; + delete rest[id]; + return rest; + } + return { ...t, [id]: stage }; + }); + setWins((w) => { + if (won) return { ...w, [id]: true }; + const rest = { ...w }; + delete rest[id]; + return rest; + }); + }, []); + + const save = useCallback( + (id: string) => { + if (trackedRef.current[id]) return; + setTracked((t) => ({ ...t, [id]: "interested" })); + void push( + () => putRow({ hackathonId: id, stage: "interested" }), + () => restore(id, undefined, false), + ); + }, + [push, putRow, restore], + ); + + const move = useCallback( + (id: string, stage: Stage) => { + const prevStage = trackedRef.current[id]; + const prevWin = winsRef.current[id] === true; + setTracked((t) => ({ ...t, [id]: stage })); + void push( + () => putRow({ hackathonId: id, stage }), + () => restore(id, prevStage, prevWin), + ); + }, + [push, putRow, restore], + ); + const remove = useCallback( - (id: string) => + (id: string) => { + const prevStage = trackedRef.current[id]; + const prevWin = winsRef.current[id] === true; setTracked((t) => { const rest = { ...t }; delete rest[id]; return rest; - }), - [], + }); + setWins((w) => { + const rest = { ...w }; + delete rest[id]; + return rest; + }); + void push( + () => + fetch(`/api/tracker?hackathonId=${encodeURIComponent(id)}`, { + method: "DELETE", + }), + () => restore(id, prevStage, prevWin), + ); + }, + [push, restore], + ); + + /** + * Record or clear a win. Claiming one also moves the hackathon to Going: you + * cannot win an event you haven't attended, and that keeps the trophy from + * appearing on something still sitting in Interested. + */ + const toggleWin = useCallback( + (id: string) => { + const prevStage = trackedRef.current[id]; + const won = winsRef.current[id] !== true; + + setWins((w) => { + if (won) return { ...w, [id]: true }; + const rest = { ...w }; + delete rest[id]; + return rest; + }); + if (won && prevStage !== "going") { + setTracked((t) => ({ ...t, [id]: "going" })); + } + + void push( + () => + putRow({ + hackathonId: id, + isWin: won, + ...(won ? { stage: "going" as Stage } : {}), + }), + () => restore(id, prevStage, !won), + ); + }, + [push, putRow, restore], ); + const isTracked = useCallback((id: string) => id in tracked, [tracked]); + const hasWin = useCallback((id: string) => wins[id] === true, [wins]); const trackerValue = useMemo( - () => ({ tracked, save, move, remove, isTracked }), - [tracked, save, move, remove, isTracked], + () => ({ + tracked, + wins, + save, + move, + remove, + isTracked, + hasWin, + toggleWin, + synced, + }), + [ + tracked, + wins, + save, + move, + remove, + isTracked, + hasWin, + toggleWin, + synced, + ], ); const selectionValue = useMemo( () => ({ selected, setSelected }), From e1d71fd1eb6195c390fc3921c9312d4008f73366 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sat, 25 Jul 2026 09:37:56 -0700 Subject: [PATCH 5/7] Show trophy badges for recorded hackathon wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts the win on screen everywhere the hackathon appears: the deck row, the detail dialog, and its tracker card. One shared badge component rather than three inlined icons, so the gold, the size and the wording cannot drift apart. The badge carries both an aria-label and a title, naming the hackathon rather than just saying "won" — a screen reader working down the deck would otherwise hear the same word repeated with nothing to attach it to. The control that records a win sits only on Going cards. A trophy on something still in Interested would not mean anything, so claiming one also moves the hackathon there. On the passport a win takes the stamp over, reading CHAMPION in the cover's foil gold instead of HACKED. The win is the more interesting fact about a hackathon than the stage it reached, and the header counts wins alongside stamps and cities. That count comes from the stamps, not from the win map, so it cannot claim a trophy the pages have no room to show. Co-authored-by: Cursor --- web/app/globals.css | 3 + web/components/hq/deck.tsx | 10 ++- web/components/hq/detail-modal.tsx | 4 +- web/components/hq/passport.tsx | 14 ++-- web/components/hq/tracker.tsx | 19 +++++- web/components/hq/trophy.tsx | 100 +++++++++++++++++++++++++++++ web/lib/passport-stamps.test.ts | 29 ++++++++- web/lib/passport-stamps.ts | 30 +++++++-- 8 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 web/components/hq/trophy.tsx diff --git a/web/app/globals.css b/web/app/globals.css index 1fe8b8c..49e1177 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -23,6 +23,9 @@ --color-soon: #f5a623; --color-register: #3b6bf0; --color-muted: #9ba1a5; + /* Trophy gold for hackathon wins (#226). Lifted from the passport cover's + foil gradient so a win reads the same on a card as it does on a stamp. */ + --color-trophy: #e7c874; } :root { diff --git a/web/components/hq/deck.tsx b/web/components/hq/deck.tsx index 18fd4b9..9126559 100644 --- a/web/components/hq/deck.tsx +++ b/web/components/hq/deck.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/types-hq"; import { safeHttpUrl } from "@/lib/url"; import { useSelection, useTracker } from "./store"; +import { TrophyBadge } from "./trophy"; type StatusFilter = "all" | HackState; type FormatFilter = "all" | "In-Person" | "Virtual"; @@ -176,6 +177,8 @@ function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) { function HackRow({ h }: { h: Hackathon }) { const { setSelected } = useSelection(); + const { hasWin } = useTracker(); + const won = hasWin(h.id); const meta = STATE_META[h.state]; const cd = countdown(h); const deadline = deadlineDisplay(h); @@ -204,8 +207,11 @@ function HackRow({ h }: { h: Hackathon }) { title={meta.label} />
-
- {h.title} +
+ + {h.title} + + {won && }
{h.host} · {h.location} diff --git a/web/components/hq/detail-modal.tsx b/web/components/hq/detail-modal.tsx index 6262ba2..9f28860 100644 --- a/web/components/hq/detail-modal.tsx +++ b/web/components/hq/detail-modal.tsx @@ -10,10 +10,11 @@ import { import { lockScroll } from "@/lib/scroll-lock"; import { safeHttpUrl } from "@/lib/url"; import { useSelection, useTracker } from "./store"; +import { TrophyBadge } from "./trophy"; export function DetailModal() { const { selected, setSelected } = useSelection(); - const { isTracked, save, remove } = useTracker(); + const { isTracked, save, remove, hasWin } = useTracker(); const panelRef = useRef(null); const closeButtonRef = useRef(null); const previousFocusRef = useRef(null); @@ -103,6 +104,7 @@ export function DetailModal() { {h.format.toUpperCase()} + {hasWin(h.id) && }
diff --git a/web/components/hq/tracker.tsx b/web/components/hq/tracker.tsx index 4dfb0c4..7834f88 100644 --- a/web/components/hq/tracker.tsx +++ b/web/components/hq/tracker.tsx @@ -6,9 +6,10 @@ import { STATE_META, countdown } from "@/lib/types-hq"; import { countKnownTracked } from "@/lib/tracker-utils"; import { safeHttpUrl } from "@/lib/url"; import { STAGES, useSelection, useTracker, type Stage } from "./store"; +import { TrophyBadge, WinToggle } from "./trophy"; export function Tracker({ hackathons }: { hackathons: Hackathon[] }) { - const { tracked, move, remove } = useTracker(); + const { tracked, move, remove, hasWin, toggleWin } = useTracker(); const { setSelected } = useSelection(); const [dragId, setDragId] = useState(null); const [overStage, setOverStage] = useState(null); @@ -104,6 +105,7 @@ export function Tracker({ hackathons }: { hackathons: Hackathon[] }) { h={h} dragging={dragId === h.id} stageId={col.id} + won={hasWin(h.id)} onDragStart={() => setDragId(h.id)} onDragEnd={() => { setDragId(null); @@ -112,6 +114,7 @@ export function Tracker({ hackathons }: { hackathons: Hackathon[] }) { onOpen={() => setSelected(h)} onRemove={() => remove(h.id)} onMoveStage={(stage) => move(h.id, stage)} + onToggleWin={() => toggleWin(h.id)} /> ))} {col.items.length === 0 && ( @@ -164,20 +167,24 @@ function TrackerCard({ h, dragging, stageId, + won, onDragStart, onDragEnd, onOpen, onRemove, onMoveStage, + onToggleWin, }: { h: Hackathon; dragging: boolean; stageId: Stage; + won: boolean; onDragStart: () => void; onDragEnd: () => void; onOpen: () => void; onRemove: () => void; onMoveStage: (stage: Stage) => void; + onToggleWin: () => void; }) { const meta = STATE_META[h.state]; const cd = countdown(h); @@ -218,6 +225,11 @@ function TrackerCard({
{h.title}
+ {won && ( +
+ +
+ )}
{h.host}
@@ -242,6 +254,11 @@ function TrackerCard({
)}
+ {/* Only in Going: a win belongs to an event you actually attended, and + claiming one from an earlier stage would silently move the card. */} + {stageId === "going" && ( + + )} {prevStage && ( + ); +} diff --git a/web/lib/passport-stamps.test.ts b/web/lib/passport-stamps.test.ts index e399685..510675b 100644 --- a/web/lib/passport-stamps.test.ts +++ b/web/lib/passport-stamps.test.ts @@ -172,7 +172,34 @@ describe("buildPassport", () => { it("returns an empty passport for no tracked hackathons", () => { const p = buildPassport({}, hackathons); - expect(p).toEqual({ left: [], right: [], stampCount: 0, cityCount: 0 }); + expect(p).toEqual({ + left: [], + right: [], + stampCount: 0, + cityCount: 0, + winCount: 0, + }); + }); + + it("stamps a recorded win as CHAMPION in gold, overriding the stage label", () => { + const p = buildPassport({ a: "going", b: "going" }, hackathons, { a: true }); + const all = [...p.left, ...p.right]; + const stamp = (id: string) => all.find((s) => s.id === id)!; + expect(stamp("a").label).toBe("CHAMPION"); + expect(stamp("a").color).toBe("#c9992f"); + expect(stamp("b").label).toBe("HACKED"); + expect(p.winCount).toBe(1); + }); + + it("ignores a win on a hackathon with no stamp, so the count matches the pages", () => { + // `ghost` isn't in the listing set and `a` is only bookmarked, so neither + // earns a stamp — and neither should be counted as a win. + const p = buildPassport({ a: "interested" }, hackathons, { + a: true, + ghost: true, + }); + expect(p.stampCount).toBe(0); + expect(p.winCount).toBe(0); }); it.each([12, 20, 40])( diff --git a/web/lib/passport-stamps.ts b/web/lib/passport-stamps.ts index f95582d..fe1c018 100644 --- a/web/lib/passport-stamps.ts +++ b/web/lib/passport-stamps.ts @@ -14,12 +14,15 @@ Which stage earns a stamp, and its label (issue #199): applied -> VISA accepted -> ADMITTED going -> HACKED `interested` (a mere bookmark) earns nothing. + + A recorded win (#226) overrides that label with CHAMPION in trophy gold. The + win is the more interesting fact about a hackathon than the stage it reached, + and the store only lets a win sit on `going`, so nothing is lost by letting it + take the stamp over. --------------------------------------------------------------------------- */ import type { Hackathon } from "./types-hq"; -// Type-only import: erased at build, so this file stays free of the "use client" -// store module at runtime (and in tests). -import type { Stage } from "@/components/hq/store"; +import type { Stage, WinMap } from "./tracker"; /** A tracker map: hackathon id -> pipeline stage. Mirrors store.tsx. */ export type TrackerMap = Record; @@ -49,6 +52,7 @@ export type Passport = { right: PassportStamp[]; // right base page stampCount: number; cityCount: number; + winCount: number; }; /* Stage -> stamp presentation. Colours mirror STAGES in store.tsx; the labels @@ -59,6 +63,11 @@ const STAGE_STAMP: Record = { going: { label: "HACKED", color: "#ed5b29" }, }; +/* The win overlay. Gold matches the `trophy` token in globals.css and the + passport cover's own foil, so the stamp reads as the same award the trophy + badges elsewhere are marking. */ +const WIN_STAMP = { label: "CHAMPION", color: "#c9992f" }; + function earnsStamp(stage: Stage): stage is StampStage { return stage !== "interested"; } @@ -224,8 +233,9 @@ function makeStamp( h: Hackathon, stage: StampStage, index: number, + won: boolean, ): PassportStamp { - const { label, color } = STAGE_STAMP[stage]; + const { label, color } = won ? WIN_STAMP : STAGE_STAMP[stage]; const name = cleanName(h.title) || h.host || "HACKATHON"; const top = name.toUpperCase(); const sub = locationArc(h.location); @@ -257,6 +267,7 @@ function makeStamp( export function buildPassport( tracked: TrackerMap, hackathons: Hackathon[], + wins: WinMap = {}, ): Passport { const byId = new Map(hackathons.map((h) => [h.id, h])); @@ -266,7 +277,9 @@ export function buildPassport( .filter((e): e is { h: Hackathon; stage: StampStage } => Boolean(e.h)) .sort((a, b) => eventTime(a.h) - eventTime(b.h)); - const stamps = earned.map((e, i) => makeStamp(e.h, e.stage, i)); + const stamps = earned.map((e, i) => + makeStamp(e.h, e.stage, i, wins[e.h.id] === true), + ); // Split half/half; earliest events fill the left (inside-cover) page first. const perPage = Math.max(1, Math.ceil(stamps.length / 2)); @@ -277,5 +290,10 @@ export function buildPassport( earned.map((e) => cityKey(e.h.location)).filter((k): k is string => Boolean(k)), ).size; - return { left, right, stampCount: stamps.length, cityCount }; + // Counted from the stamps rather than from `wins` directly: a win on a + // delisted hackathon has no stamp to show, so counting it would leave the + // header claiming a trophy the pages don't have. + const winCount = earned.filter((e) => wins[e.h.id] === true).length; + + return { left, right, stampCount: stamps.length, cityCount, winCount }; } From 060f2b6f61cbdd37ae966ea20e58eaff0923e0f6 Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 11:13:22 -0700 Subject: [PATCH 6/7] Overlay a gold trophy stamp on won hackathons Add a large trophy over-stamp, drawn in the same rough ink-stamped style as the passport's other visas, that lands on top of the CHAMPION stamp for recorded wins. The generator now flags won stamps explicitly so the renderer can layer and lift them above their neighbours. Co-authored-by: Cursor --- web/components/hq/passport.tsx | 65 ++++++++++++++++++++++++++++++++++ web/lib/passport-stamps.ts | 2 ++ 2 files changed, 67 insertions(+) diff --git a/web/components/hq/passport.tsx b/web/components/hq/passport.tsx index b10aa9e..ab88068 100644 --- a/web/components/hq/passport.tsx +++ b/web/components/hq/passport.tsx @@ -154,6 +154,53 @@ function StampMark({ stamp, index }: { stamp: Stamp; index: number }) { ); } +/* The win over-stamp: a large gold trophy slapped on top of a CHAMPION stamp + (#226). Same 220x220 coordinate space and rough-inked treatment as + — multiply blend + the shared pp-ink turbulence filter — so it + reads as the same foil ink, just bolder so it dominates the stamp beneath. + Flat gold (the win token), with the lighter trophy gold for the star. */ +function TrophyStamp() { + const gold = "#c9992f"; + const star = "#e7c874"; + return ( + + + {/* rim + bowl */} + + + {/* handles */} + + + {/* stem, collar, plinth */} + + + {/* star on the cup face */} + + + + ); +} + function StampLayer({ stamps, indexOffset, @@ -176,11 +223,29 @@ function StampLayer({ width: s.pos.size, height: s.pos.size, transform: `rotate(${s.rotate}deg)`, + // Won stamps sit above their neighbours so the trophy over-stamp + // isn't clipped by the next overlapping stamp in a dense grid. + zIndex: s.won ? 5 : 1, }} >
+ {s.won && ( +
+ +
+ )}
))} diff --git a/web/lib/passport-stamps.ts b/web/lib/passport-stamps.ts index fe1c018..86e99f9 100644 --- a/web/lib/passport-stamps.ts +++ b/web/lib/passport-stamps.ts @@ -39,6 +39,7 @@ export type PassportStamp = { mono: string; // big monogram in the middle label: string; // HACKED / VISA / ADMITTED color: string; + won: boolean; // true -> render the gold trophy over-stamp on top pos: { left: number; top: number; size: number }; rotate: number; delay: number; // stamp-in animation delay, ms @@ -248,6 +249,7 @@ function makeStamp( mono, label, color, + won, rotate: rotateFor(h.id), // Stagger the stamp-in, but cap it so a large passport doesn't leave the // last stamps waiting several seconds to appear. From cc3b80d59c82637a6db658c08eff0d3063e587db Mon Sep 17 00:00:00 2001 From: allykeightley Date: Sun, 26 Jul 2026 13:01:45 -0700 Subject: [PATCH 7/7] remove extra in progress badge --- web/components/hq/resources.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/components/hq/resources.tsx b/web/components/hq/resources.tsx index 8bd952e..253525e 100644 --- a/web/components/hq/resources.tsx +++ b/web/components/hq/resources.tsx @@ -141,7 +141,6 @@ function ToolsStrip() {
Toolkit · Always useful

Tools & templates -

Steal these for any weekend—first hackathon or fiftieth.