Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
hackhq | e333148 | Jul 28 2026, 02:28 AM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/myhave been localStorage-only since they shipped. Clearyour 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/trackerwhether this session has somewherebetter than the browser to put things. Three answers, all fine:
200 { synced: false }401200 { synced: true }It asks the route rather than reading Clerk hooks, because the provider also
mounts on deployments with no
<ClerkProvider>above it, where those hooksthrow.
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-v1flag. The import is additive(
ignoreDuplicates: true), so an account that already tracks a hackathon keepsthe 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 inand whether the user won it.
user_idis the Clerksub, matching thesubmitted_byconvention already onpublic.hackathons.Deliberately no foreign key to
public.hackathons. That table is a mirrorthe hourly
sync_supabase.ymlcron 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.jsonowns hackathon content;user_hackathonsownsper-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-labeland atitlenaming 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.tsClerk's shared modules import Node built-ins (
#crypto,#safe-node-apis). OnEdge that is the "Edge Function is referencing unsupported modules" build
error, so on Vercel auth has to run on Node. Next 16 runs
proxy.tson Node,and per the Next docs the
runtimeoption is not available in Proxy filesand 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
mainstill documented Edge middleware and Cloudflare Workers asthe 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:
proxy.tsmiddleware.tsNode.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
mainThis 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 atbuild 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
mainunshipped. Once merged, repoint the Vercel project'sproduction branch to
mainor the problem simply follows the next branch.Config
Two new server-only variables (documented in
web/.env.exampleand now inthe README's production table). Both optional; without them the tracker stays
browser-local exactly as today.
isTrackerSyncConfigured()is deliberately Clerk-inclusive — with Supabaseconfigured 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-jsover HTTP, so it stays host-portable (unlike thepostgres/drizzle TCP path, which isdb:*scripts only).Auth model, and what's deferred
lib/tracker-store.tsauthenticates with the service role key, whichbypasses RLS. Ownership is enforced there instead:
.eq("user_id", userId)onevery read, update and delete, and
user_idwritten explicitly on every insert.The
userIdalways comes from the Clerk session resolved in the route handler,never from a request body.
The RLS policies aren't redundant —
anonandauthenticatedcan't touch thetable at all, so nothing reachable with a publishable key can read a tracker.
This branch also revokes Supabase's stock
ALTER DEFAULT PRIVILEGESgrants fromanon, and stripsTRUNCATEfromauthenticated(TRUNCATE is not subject toRLS, 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_hackathonsholds nouser data yet.
Build determinism
Next infers its workspace root by walking up for a lockfile, so a stray
package-lock.jsonaboveweb/wins. On one dev machine it selected one in$HOMEand built rooted outside the repo. Root is now pinned toweb/, whichalso matches what the app needs: since #230 nothing outside
web/is read atbuild, and Turbopack doesn't resolve outside the root — so the invariant is now
enforced rather than incidental.
Migration ledger
20260725154500_user_hackathons.sqlwas applied by hand through the SupabaseSQL Editor, because this project has no CLI or MCP
apply_migrationconfigured.That records nothing in
supabase_migrations.schema_migrations, so unlike everyother file in that directory its timestamp is not a recorded version. Both
the file header and
supabase/migrations/README.mdnow say so, including how toreconcile 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.tscovers sharedvalidation and map/entry conversion,
lib/env.test.tsthe configuration matrix,lib/tracker-store.test.tsasserts every query is scoped to the caller — theone that matters most, since service role means a missing filter would be a
cross-user read with no database backstop.
lib/passport-stamps.test.tsextendedfor win stamps.
Verified locally against exactly what
web-ci.ymlruns:lint,test,build,tsc --noEmit— all clean. Build output confirms the render contract isunchanged: ISR routes still
Revalidate 1h,/resourcesnone,/auth/[[...auth]]and
/api/trackerdynamic.After merge
main(needs dashboard access)hackhq.dev— README still says "coming soon" (needs dashboard access)