Skip to content

feat: persist the tracker to the user's account, add win trophies, and deploy from main (#226, #223) - #236

Draft
akeight wants to merge 18 commits into
mainfrom
vercel
Draft

feat: persist the tracker to the user's account, add win trophies, and deploy from main (#226, #223)#236
akeight wants to merge 18 commits into
mainfrom
vercel

Conversation

@akeight

@akeight akeight commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #226. Closes #223. Supersedes #234 — that PR's commits are all contained
here, so merge this one and close #234, not both.

Branch work by @allykeightley and @Jose-Gael-Cruz-Lopez.

TL;DR

Three things land together because they are the same shipping story: the
tracker stops living only in the browser, wins get their trophy badges, and
the app becomes deployable to the host it is actually running on.


1. The tracker moves onto the user's account (#226)

The tracker and /my have been localStorage-only since they shipped. Clear
your cache or open the site in another browser and your entire pipeline is
gone — and with wins added, those too.

On mount the provider asks /api/tracker whether this session has somewhere
better than the browser to put things. Three answers, all fine:

Response Meaning
200 { synced: false } Clerk or Supabase not configured — stay local
401 Signed out — stay local
200 { synced: true } Adopt the account's rows as the truth

It asks the route rather than reading Clerk hooks, because the provider also
mounts on deployments with no <ClerkProvider> above it, where those hooks
throw.

localStorage does not go away. It stays as the signed-out path and as the
fallback wherever Clerk or Supabase isn't configured.

Handover on first sign-in. A browser's existing tracker is POSTed once,
guarded by a hackhq-tracker-imported-v1 flag. The import is additive
(ignoreDuplicates: true), so an account that already tracks a hackathon keeps
the stage it has on the server — signing in on a second device can't roll your
pipeline back to whatever that browser happened to remember. Without the flag a
later visit would resurrect rows the user had since deleted.

Writes are optimistic and revert on failure. 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.

Schema

public.user_hackathons — one row per (user, hackathon): the stage it sits in
and whether the user won it. user_id is the Clerk sub, matching the
submitted_by convention already on public.hackathons.

Deliberately no foreign key to public.hackathons. That table is a mirror
the hourly sync_supabase.yml cron can leave up to an hour behind
.github/scripts/listings.json — which is what the app actually renders from.
An FK would reject saving any listing added since the last sync. The id is
validated in the app layer against the live listing set instead.

Worth being explicit, since the repo is the source of truth for listings: it
still is.
listings.json owns hackathon content; user_hackathons owns
per-user state, which the repo structurally cannot hold. They don't overlap.

2. Wins (#226)

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. The badge appears
everywhere the hackathon does — deck row, detail dialog, tracker card — from
one shared component, so the gold, the size and the wording can't drift apart.
It carries both an aria-label and a title naming the hackathon, not just
"won": a screen reader working down the deck would otherwise hear the same word
repeated with nothing to attach it to.

Claiming a win also moves the hackathon to Going — a trophy on something still
in Interested wouldn't mean anything. On the passport a win takes the stamp
over, reading CHAMPION in the cover's foil gold instead of HACKED.

3. Deployment: Vercel, from main (#223)

Why proxy.ts

Clerk's shared modules import Node built-ins (#crypto, #safe-node-apis). On
Edge that is the "Edge Function is referencing unsupported modules" build
error, so on Vercel auth has to run on Node. Next 16 runs proxy.ts on Node,
and per the Next docs the runtime option is not available in Proxy files
and throws if set. So this is construction, not preference.

Why the docs changed

The rename earlier on this branch changed the file but not the eight places
naming it, so main still documented Edge middleware and Cloudflare Workers as
the production target while production had already moved to Vercel. That
contradiction is what produced two competing draft PRs.

The Cloudflare files stay. The #230 runtime work still stands — no
request-time filesystem dependency, so the app still builds for Workers. What
blocks it is auth, and it's a genuine either/or:

Vercel Cloudflare / OpenNext
File convention proxy.ts middleware.ts
Runtime Node Edge
The other fails with Edge rejects Clerk's Node built-ins Node.js middleware is not currently supported.

Reviving Cloudflare means renaming back, which breaks Vercel the moment it
lands. The README now says that outright, and the clean exit is upstream
Node-proxy support rather than a local workaround.

Production must deploy from main

This is the part with teeth. Six workflows push listing updates to main
closing_soon, auto_extract, contribution_approved, update_readmes,
gallery, gallery_approved — and listing data is frozen into the bundle at
build time. Those commits only reach users because they trigger a rebuild.
There is no deploy workflow; the Vercel Git integration is the pipeline.

Production is currently serving this branch, which is why today's closing-soon
badge update sat on main unshipped. Once merged, repoint the Vercel project's
production branch to main
or the problem simply follows the next branch.

Config

Two new server-only variables (documented in web/.env.example and now in
the README's production table). Both optional; without them the tracker stays
browser-local exactly as today.

SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=

isTrackerSyncConfigured() is deliberately Clerk-inclusive — with Supabase
configured but sign-in off there's no user id to scope a row by — and
validateEnv() warns on both half-configured cases. Uses
@supabase/supabase-js over HTTP, so it stays host-portable (unlike the
postgres/drizzle TCP path, which is db:* scripts only).

Auth model, and what's deferred

lib/tracker-store.ts authenticates with the service role key, which
bypasses RLS. Ownership is enforced there instead: .eq("user_id", userId) on
every read, update and delete, and user_id written explicitly on every insert.
The userId always comes from the Clerk session resolved in the route handler,
never from a request body.

The RLS policies aren't redundant — anon and authenticated can't touch the
table at all, so nothing reachable with a publishable key can read a tracker.
This branch also revokes Supabase's stock ALTER DEFAULT PRIVILEGES grants from
anon, and strips TRUNCATE from authenticated (TRUNCATE is not subject to
RLS, so it must never sit on an API role).

Moving enforcement into Postgres needs a trust relationship configured in two
dashboards, so it's #235, not a blocker here — user_hackathons holds no
user data yet.

Build determinism

Next infers its workspace root by walking up for a lockfile, so a stray
package-lock.json above web/ wins. On one dev machine it selected one in
$HOME and built rooted outside the repo. Root is now pinned to web/, which
also matches what the app needs: since #230 nothing outside web/ is read at
build, and Turbopack doesn't resolve outside the root — so the invariant is now
enforced rather than incidental.

Migration ledger

20260725154500_user_hackathons.sql was applied by hand through the Supabase
SQL Editor, because this project has no CLI or MCP apply_migration configured.
That records nothing in supabase_migrations.schema_migrations, so unlike every
other file in that directory its timestamp is not a recorded version. Both
the file header and supabase/migrations/README.md now say so, including how to
reconcile if a CLI is ever wired up. Flagging it rather than leaving the
directory's core invariant quietly false.

Testing

180 tests pass (up from 136 on main). lib/tracker.test.ts covers shared
validation and map/entry conversion, lib/env.test.ts the configuration matrix,
lib/tracker-store.test.ts asserts every query is scoped to the caller — the
one that matters most, since service role means a missing filter would be a
cross-user read with no database backstop. lib/passport-stamps.test.ts extended
for win stamps.

Verified locally against exactly what web-ci.yml runs: lint, test,
build, tsc --noEmit — all clean. Build output confirms the render contract is
unchanged: ISR routes still Revalidate 1h, /resources none, /auth/[[...auth]]
and /api/tracker dynamic.

After merge

akeight and others added 14 commits July 25, 2026 08:46
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <ClerkProvider> 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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
…se-tracker-wins

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	web/package-lock.json
The service-role client bypasses RLS, so row ownership is enforced in
tracker-store.ts by filtering on user_id. Lock that guarantee in: reads,
updates and deletes must filter by the caller's user_id (and hackathon_id
where applicable), and writes must stamp user_id onto the row. A dropped
filter would leak or overwrite another user's tracker with no DB backstop.

Co-authored-by: Cursor <cursoragent@cursor.com>
Vercel runs Next middleware as an Edge Function, but Clerk pulls Node
built-ins (#crypto, #safe-node-apis) the Edge runtime rejects — the
"Edge Function is referencing unsupported modules" deploy error. Next 16
runs proxy.ts on the Node runtime, so restore that convention for this
Vercel branch. Same auth logic; the Cloudflare/OpenNext branch keeps the
Edge middleware.ts form it requires.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Applied by hand in the Supabase dashboard (no CLI/MCP configured), so it
isn't in schema_migrations and its timestamp stays a placeholder. Update
the file header and migrations README to state that plainly instead of
claiming it's unapplied.

Co-authored-by: Cursor <cursoragent@cursor.com>
New public tables inherit anon/authenticated grants from Supabase's stock
ALTER DEFAULT PRIVILEGES. A per-user tracker must not: revoke everything
from anon, and drop TRUNCATE/REFERENCES/TRIGGER from authenticated
(TRUNCATE bypasses RLS). RLS already blocked anon row access, so this is
defense-in-depth plus matching the migration's stated intent.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hackhq Ready Ready Preview, Comment Jul 28, 2026 2:27am

Request Review

Jose-Gael-Cruz-Lopez and others added 3 commits July 27, 2026 14:25
The middleware.ts -> proxy.ts rename earlier on this branch changed the file
but not the eight places that name it, so the README still documented Edge
middleware and Cloudflare Workers as the production target while production
had already moved to Vercel.

Deployment section now states the real target and, more importantly, why it
must deploy from `main`: six workflows push listing updates there, listing
data is frozen into the bundle at build time, so those commits only reach
users by triggering a rebuild. Deploying from a long-lived branch silently
strips the site of every automated listing update.

Cloudflare files stay. What blocks that path is auth and it is a genuine
either/or -- Clerk needs Node (proxy.ts) on Vercel, OpenNext accepts only Edge
(middleware.ts) -- so the section says plainly that preview/deploy fail today,
that reviving them breaks Vercel the moment it lands, and that the clean exit
is upstream Node-proxy support rather than a local workaround.

Also documents the two Supabase variables the tracker needs in production, and
drops the stale claim about a middleware->proxy deprecation warning: that
warning is gone precisely because the file is now proxy.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Next infers the workspace root by walking up for a lockfile, so any stray
package-lock.json above web/ wins. On this machine it selected one in $HOME
and built the app rooted outside the repo entirely.

Two fixes, because the stray file and the inference are separate problems.
b7f323b already had to delete an accidentally committed root lockfile once and
it had come back untracked, so gitignore it -- there is no npm project at the
repo root to need one. That alone was not enough, since the winning lockfile
was outside the repo where gitignore cannot reach, hence pinning root
explicitly.

Pinning also matches what the app actually needs. Since #230 nothing outside
web/ is read at build -- repo data is copied into lib/generated/ first -- and
Turbopack does not resolve files outside the root, so this turns that
invariant into something enforced rather than incidental.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez changed the title Vercel branch deployed feat: persist the tracker to the user's account, add win trophies, and deploy from main (#226, #223) Jul 27, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
hackhq e333148 Jul 28 2026, 02:28 AM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configure and Complete Production Deployment Display Trophy Badges for Hackathon Wins

2 participants