Who brought you? A single-page site where festival-goers enter who invited them each year and explore the full invite lineage as a draggable, zoomable family tree. Neon-on-black, Win95 windows, scanlines. Netscape 4.0 recommended.
Stack: Next.js 16 (App Router) · React 19 · d3-force / d3-hierarchy / d3-zoom / d3-drag · Postgres on Neon via pg · Vercel.
- People have one name, unique across the tree (case-insensitive). Most people will just use a first name; if "Alice" is taken by someone else, the form asks you to go by "Alice K" or a nickname. The type-ahead shows each match's years, who brought them and who they brought, so you can confirm it's really you (or really them) before picking.
- Attendances are one row per (person, year) with an optional
invited_by_id.NULLmeans they got in on their own (founders, artists, staff), they came back on their own in a later year, or nobody has filled it in yet. The UI tells the first two apart by whether it's the person's first year ("got in on their own" vs "came back"). The year is the year you were invited / attended; people who went several times add one entry per year. - Years are shown as editions:
Year 10 (2024). 2014 is Year 1; 2020 (no festival) is skipped and not selectable. SeeSKIPPED_YEARSinlib/config.ts. - Festival rules are enforced in the database and surfaced as friendly errors:
one entry per person per year, one guest per inviter per year, unique names,
and you can't bring yourself. If you say Bob brought you in 2024, Bob is also
recorded as attending 2024, and if Bob has nothing earlier he's pencilled in
for 2023 too so he sits in the row above you rather than beside you (guests
rarely know their host's real first year; Bob can fix it). Two more rules live
in
lib/mutations.ts: you can't be brought and bring a guest in the same year, and someone whose first year is later than yours can't have brought you (the usual sign the invite was entered backwards). Both come back as errors that name the people involved and suggest the fix. - Graph: a layered family tree. Each row is a festival year, labelled on the left; a person sits in the row of their first year, placed under whoever brought them (tidy-tree layout), with a light force simulation keeping nodes apart. Solid edges are the invite that put someone on the tree; dashed edges are later-year invites of returning attendees. Node size = how many people they've brought; white ring = never invited by anyone (OG). Click a node to light up its full lineage up and down. Drag pins a node, double-click unpins it. Year chips at the bottom solo/toggle years; the find box flies to a name.
- Editing: click a person to open their panel. From there you can rename
them, edit any year (the year itself, who brought them, and who they brought),
delete a year, add another year, or delete the person. Deleting a person is a
soft delete: they disappear from the tree and their invite links are cut both
ways (people they brought show as brought by nobody), but their rows stay so
an admin can bring them back with
UPDATE people SET deleted_at = NULL WHERE id = …in the Neon console. The same rules apply as when adding. A person with no remaining entries who brought nobody is removed automatically. Changing a year moves the guest they brought along with it. To fix an invite entered backwards, set one side to "Nobody" and save, then re-enter it the right way round. - Rate limiting: writes are limited to 30 per IP per minute, counted in
Postgres so the limit holds across serverless instances (
lib/rate-limit.ts). Over the limit returns a 429 with a friendly message. Reads aren't limited. It's only active in production by default, since every local request shares one IP;RATE_LIMIT=on|offoverrides that andRATE_LIMIT_PER_MINUTEthe limit. - Next year: the year pickers stop at the current year until
NEXT_PUBLIC_ALLOW_NEXT_YEAR=trueis set, so nobody pre-registers for an edition that hasn't been announced. Rows that already hold a future year can still be edited. - Phones (≤ 760px): the windows stack at the bottom and only one is open at a time. Selecting a person collapses the add form; expanding the add form closes the person window.
- Sharing:
app/opengraph-image.png/twitter-image.pngprovide the link preview,app/icon.svgandapp/apple-icon.pngthe favicons. Next.js picks these up by filename. SetNEXT_PUBLIC_SITE_URLif you use a custom domain so absolute preview URLs are right; on Vercel it falls back to the production URL. - Demo mode: with no
DATABASE_URLthe app serves a fictional tree so the UI can be previewed; writes are disabled.
npm install
cp .env.example .env.local # paste your Neon pooled connection string
npm run db:migrate # creates tables (idempotent)
npm run db:seed # optional: fictional demo data into an EMPTY db
npm run dev # http://localhost:3000Skip the .env.local step to run in demo mode without a database.
- Push this folder to a Git repo and import it into Vercel as a new project. Framework preset: Next.js. No build settings needed.
- In the Vercel project, open Storage → Create Database → Neon (or connect an
existing Neon project via the Neon integration). This sets
DATABASE_URLon the project automatically. If you set it by hand, use the pooled connection string from the Neon console (host contains-pooler). - Create the tables once. Either run locally against the Neon URL:
or paste
DATABASE_URL="postgresql://…-pooler…/neondb?sslmode=require" npm run db:migratedb/schema.sqlinto the Neon SQL editor. - Deploy. Any later push redeploys.
Re-run npm run db:migrate whenever db/schema.sql changes; it's idempotent.
The app expects the current schema (people.deleted_at, rate_limits) and the
graph won't load without it.
Optional: npm run db:seed -- --force wipes and loads the demo tree; don't run it
against a database people have already added themselves to.
| Command | What it does |
|---|---|
npm run dev |
Next dev server |
npm run build |
Production build |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint over the project (lint:fix applies fixes) |
npm run format |
Prettier over the project (format:check reports) |
npm test |
Rule tests against TEST_DATABASE_URL (see Testing) |
npm run test:docker |
Same, against a throwaway Postgres 16 in Docker |
npm run check |
lint + typecheck + format:check + test |
npm run check:docker |
Same, with the Docker-backed tests |
npm run db:migrate |
Apply db/schema.sql (safe to re-run) |
npm run db:seed |
Load demo data into an empty db (-- --force to wipe) |
test/rules.test.ts runs every festival rule, the guest handling, soft delete
and the rate limiter against a real Postgres, using the same functions the API
routes call. The harness (test/db.ts) creates a throwaway schema, applies
db/schema.sql into it, runs each transaction with search_path pointed
there, and drops the schema afterwards, so nothing outside it is touched.
Two ways to give it a database:
- Docker:
npm run test:dockerstarts apostgres:16-alpinecontainer on a free port, runs the tests, and removes it. No configuration needed. - Neon branch: create a branch of the production project in the Neon
console, copy its connection string into
.env.test.localasTEST_DATABASE_URL, and runnpm test. The harness refuses to run if that URL equalsDATABASE_URL.
npm run check (or check:docker) chains lint, typecheck, format check and the
tests; that's the pre-merge gate.
Prettier owns formatting: no semicolons, double quotes, trailing commas, 110
columns (.prettierrc). ESLint (eslint.config.mjs) runs the Next.js
core-web-vitals and TypeScript rule sets plus eslint-config-prettier so the two
never disagree, and adds curly: all so every if/else/loop body has braces
on its own lines. .vscode/settings.json formats with Prettier and applies
ESLint fixes on save; .vscode/extensions.json recommends both extensions.
Before handing work over, npm run check (or npm run check:docker) should be
clean.
GET /api/graph→{ people: [{ id, name }], attendances: [{ id, personId, year, invitedById }], demo }POST /api/attendances→{ person, year, inviter, guest? }whereperson,inviterandguestare{ id }for an existing person or{ name }for a new one;invitermay benull("got in on my own").guestis whopersonbrought that year: omit to leave alone,nullfor nobody. Returns{ ok: true, personId, inviterId }or{ ok: false, error }with a message meant to be shown to the user.PATCH /api/attendances/:id→{ year?, inviter?, guest? }(omit to keep,inviter: nullfor "on my own",guest: nullfor "brought nobody"). Ifyearchanges andguestis omitted, the current guest moves to the new year.DELETE /api/attendances/:idremoves the entry, unless that person brought someone that year (set the guest to nobody first).PATCH /api/people/:id→{ name }renames a person (must stay unique).DELETE /api/people/:idsoft-deletes them (see Editing above).- All mutations return
{ ok: true }or{ ok: false, error }; 429 when the per-IP write limit is hit. Request bodies are validated with the zod schemas inlib/schemas.ts, which the TypeScript request types are inferred from.
app/
api/graph/route.ts read the whole tree
api/attendances/route.ts add / complete an entry
api/attendances/[id]/route.ts edit or delete an entry
api/people/[id]/route.ts rename or delete a person
layout.tsx, page.tsx, globals.css
icon.svg, apple-icon.png, opengraph-image.png, twitter-image.png
components/
App.tsx page shell: top bar, HUD windows, year legend, find box, phone layout
InviteGraph.tsx layered tree (d3-hierarchy) + force collision, zoom, pan, drag, lineage highlight
AddForm.tsx the four-step "add yourself" form (who, year, who brought you, who you brought)
PersonPicker.tsx type-ahead that finds people / creates new unique names
PersonPanel.tsx selected person's history + per-year editor (inviter and guest)
ConfirmDialog.tsx themed confirm dialog (useConfirm hook) replacing window.confirm
Win95Window.tsx bevelled window chrome
lib/
db.ts pg pool + transaction helper
mutations.ts all festival rules, guest handling, soft delete, error mapping for writes
rate-limit.ts per-IP write limiter backed by the rate_limits table
schemas.ts zod request schemas (single source of truth for the API)
api-client.ts browser-side fetch helpers shared by the form and the panel
demo.ts fictional lineage for demo mode and seeding
names.ts name tidying + case-insensitive key
config.ts first year, skipped years, "Year N (YYYY)" labels, palette
types.ts shared types; request shapes inferred from schemas.ts
test/
db.ts throwaway-schema harness for a real Postgres
rules.test.ts every rule, guest flow, soft delete and the rate limiter
db/schema.sql, db/migrations/
scripts/migrate.ts, scripts/seed.ts, scripts/test-docker.sh
eslint.config.mjs, .prettierrc, .prettierignore, .vscode/
- There is no auth. Anyone can add anyone. That's the point for a community tree. Vandalism is slowed by the per-IP rate limit and undone by restoring soft-deleted people or, for anything worse, Neon's point-in-time restore. If it gets bad, put the site behind Vercel password protection or add a shared passphrase check to the write routes.
- Corrections (wrong inviter, wrong year, misspelled name, duplicate person) are all done from the person panel; nothing needs a SQL edit. The API still refuses to silently overwrite an existing inviter through the add form, so a typo can't rewrite someone's history without an explicit edit.
- The Neon SQL editor runs one statement per submission. Paste multi-statement
scripts one statement at a time, or use
psql -f. - The selectable year range is 2014 through next year, minus skipped years
(
lib/config.ts).