diff --git a/BACKEND_GUIDE.md b/BACKEND_GUIDE.md
new file mode 100644
index 0000000..eabc365
--- /dev/null
+++ b/BACKEND_GUIDE.md
@@ -0,0 +1,261 @@
+# Backend guide β the other half (Part 2 of 2)
+
+You made it to Part 2. π If you've done the frontend guide and its two exercises, you already
+know more than you think β you just know it from the *dining room* side. This guide walks you
+through the kitchen door.
+
+You don't strictly need any of this to make the frontend beautiful. But every `api.β¦` call you
+wrote in Part 1 disappeared through a door, and I don't want that door to feel like magic.
+Understanding what's behind it will make you noticeably better at the frontend too β you'll
+know *why* an endpoint returns what it does, and what's easy vs. hard to change.
+
+> **Read [`FRONTEND_GUIDE.md`](./FRONTEND_GUIDE.md) first.** This guide reuses its restaurant
+> picture (dining room / waiter / kitchen / pantry / suppliers) and starts exactly where it
+> stopped: the moment the waiter pushes through the kitchen door.
+
+The kitchen's whole job, in one sentence: **keep a fast, safe copy of the truth in the pantry,
+and plate up exactly the right slice of it as JSON.** Everything below is just *how*.
+
+> It's all under `src/server/`, and it's refreshingly boring tech on purpose: **TypeScript +
+> Express** (the web server) talking to **Postgres** (the database) through **Drizzle** (a
+> type-safe way to write database queries). No mysterious framework magic.
+
+---
+
+## 1. What happens after the waiter pushes through the door
+
+In Part 1, a page asked the waiter (`web/src/api.ts`) for data and the waiter did a `fetch` to
+`/api/β¦`. Here's the rest of that same journey β the kitchen side:
+
+```
+ the waiter's order arrives: GET /api/public/past
+ β
+ βΌ
+ src/server/index.ts β the kitchen door. Sends each order to the right station.
+ β "/api/public/β¦" β the publicRouter station.
+ βΌ
+ src/server/routes/public.ts β the station. The actual code that fills this order.
+ β
+ βΌ
+ a Drizzle query on Postgres β "grab the matching rows from the pantry"
+ β db.select().from(events).where(...)
+ βΌ
+ res.json({ events: [...] }) β plate it up as JSON and hand it back to the waiter
+```
+
+That's the whole shape. For *any* endpoint in the app, you only ever need to answer two
+questions:
+
+1. **Which file fills this order?** β open `src/server/index.ts`; it maps each URL prefix to a
+ station (a "router").
+2. **What does that station do?** β open the route file; most handlers are 5β15 readable lines.
+
+If you can answer those two, you can find and understand any behaviour in the backend.
+
+---
+
+## 2. The kitchen, room by room
+
+Everything's under `src/server/`. You can ignore most of it at first β the two files that
+matter most are marked.
+
+```
+src/server/
+ index.ts β THE KITCHEN DOOR. Wires every station to a URL. Read this first.
+ env.ts Reads + checks environment variables. Refuses to start if a required
+ secret is missing β a loud failure now beats a mystery crash at 2am.
+
+ db/
+ schema.ts β THE MOST IMPORTANT FILE. Describes every pantry shelf (table).
+ index.ts The pantry connection β the `db` object you run queries on.
+ migrate.ts Applies database changes on startup.
+
+ auth/
+ jwt.ts Checks sign-in tokens from mac-auth. We never build our own login.
+ middleware.ts requireAuth / requireOrganiser β the "members only" guards for routes.
+
+ routes/ One file per area. Each is a station wired up in index.ts.
+ health.ts "is the kitchen alive?"
+ public.ts public site data (no sign-in)
+ me.ts "who am I?"
+ dashboard.ts everything the participant dashboard needs
+ teams.ts create / join / invite / leave teams
+ events.ts organiser event management
+ tickets.ts trigger ticket syncs / CSV import
+ organiser.ts the gap report, override queue, CSV exports
+ stats.ts π YOUR EXERCISE (a stub β see Β§6)
+
+ content/ the Notion β pantry delivery (public-site content)
+ tickets/ the Humanitix β pantry delivery (who holds a ticket)
+ teams/ team status rules (status is worked out, never set by hand)
+ participants/ ticket-verification logic
+ lib/ small shared helpers (audit log, Discord alerts, codesβ¦)
+```
+
+Honestly, if you read just two files to "get" the backend, read **`index.ts`** (how orders
+find their station) and **`db/schema.ts`** (what data exists at all). Everything else is a
+variation on the pattern in Β§1.
+
+---
+
+## 3. The pantry, via Drizzle
+
+The database keeps data in **tables** β think spreadsheets: an `events` table, a
+`participants` table, a `teams` table, and so on. We never hand-write SQL; instead we use
+**Drizzle**, which lets us describe each table in TypeScript and then query it with real
+autocomplete and type-checking (so your editor catches typos before the code ever runs).
+
+Here's a table, trimmed from `db/schema.ts`:
+
+```ts
+export const events = pgTable("events", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ slug: text("slug").notNull(), // e.g. "2026"
+ name: text("name").notNull(),
+ isPublished: boolean("is_published").notNull().default(false),
+ isArchived: boolean("is_archived").notNull().default(false),
+ // β¦more columns
+});
+```
+
+And here's a real query against it, from `routes/public.ts` β notice it reads almost like a
+sentence:
+
+```ts
+const rows = await db
+ .select()
+ .from(events)
+ .where(and(eq(events.isPublished, true), eq(events.isArchived, true)))
+ .orderBy(desc(events.startsAt));
+```
+
+`eq` = "equals", `and` = "both of these are true", `desc` = "newest first". That small
+vocabulary covers most of what you'll ever write.
+
+**Changing the pantry's shelves** β adding a column or a whole table β is a little two-step
+ritual worth knowing exists (you won't need it for the exercise, which only reads):
+
+1. Edit `db/schema.ts`.
+2. Run `npm run db:generate`. Drizzle writes a **migration** β a `.sql` file in `drizzle/`
+ describing the change β and `npm run db:migrate` applies it.
+
+Migrations are how the database changes the same way everywhere: your laptop, staging, and
+production, all in the right order, without anyone hand-editing a live database at midnight.
+
+---
+
+## 4. Why the kitchen looks so careful (the house rules)
+
+You'll notice the backend is written defensively in places. That's not paranoia β each rule
+below comes from a real "imagine if this went wrong the night before the event" scenario. You
+don't need to memorise them; just recognise them when you see them, because they explain a lot
+of the code's shape:
+
+- **Never send raw pantry rows out to guests.** Public endpoints hand back a hand-picked
+ *whitelist* of fields (see `toPublicEvent()` in `public.ts`). Internal things β a Humanitix
+ id, someone's personal details β must never slip into a public response. When you write an
+ endpoint, choose on purpose what goes in it.
+- **Check "members only" on the server, every single time.** A hidden button in the dining
+ room is *not* security β anyone can call the API directly. Protected routes put a guard in
+ front: `router.get("/me", requireAuth, handler)` (see `me.ts`), and organiser-only routes
+ add `requireOrganiser`. The sign-in token is re-checked on every request; we never just
+ trust what the browser claims to be.
+- **Cook from the pantry, not from the supplier, on a page load.** Notion and Humanitix are
+ synced into Postgres on a timer (that's the `content/` and `tickets/` folders). So if Notion
+ is down, the site shrugs and serves the last good copy. A page must be fast and must never
+ depend on someone else's API being up at that exact second.
+- **Nothing is ever truly deleted.** "Deleting" flips a flag (`isArchived`, a `withdrawn`
+ status); the row stays. And every meaningful action gets written to an append-only
+ `audit_log`, so we can always answer "who changed this, and when?"
+- **Some things are worked out, not stored.** A team's status (`forming` / `confirmed` / β¦)
+ is *calculated* from its members and their tickets (`teams/status.ts`), never set by hand β
+ so it can never drift out of step with reality.
+
+The clearest example of this mindset is `tickets/sync.ts`. It has a **safety gate** that flat
+-out refuses to un-verify a big chunk of attendees in a single sync β because the difference
+between a normal bug and "200 people are locked out an hour before doors open" is enormous.
+Have a read of the comments there someday; it's a lovely example of thinking about what happens
+when things go wrong, which is most of what senior backend work actually is.
+
+---
+
+## 5. Running and poking at the kitchen
+
+Setup is identical to Part 1 (`npm install`, `docker compose up -d db`, `npm run db:seed`,
+`npm run dev`). The nice thing about the backend is you can talk to it directly from the
+terminal β no browser needed β using `curl`, which is just "make a web request from the
+command line":
+
+```bash
+curl http://localhost:3000/api/health # {"status":"ok","db":"ok"}
+curl http://localhost:3000/api/public/past # {"events":[...]}
+curl http://localhost:3000/api/public/stats # your exercise β see Β§6
+```
+
+Poking one endpoint at a time like this is the fastest way to understand it in isolation. (For
+routes that need sign-in it's easier to test through the running site with dev sign-in β but
+you won't need auth for the exercise.)
+
+**When the backend misbehaves, watch the terminal running `npm run dev`.** That's where server
+errors and any `console.log` you add show up. (The *browser* console only shows dining-room
+errors β a common early confusion. Frontend problems: browser console. Backend problems:
+terminal.)
+
+---
+
+## 6. Exercise β build your first endpoint (`src/server/routes/stats.ts`)
+
+This is the mirror image of frontend Exercise 1: there you drew data the kitchen already sent;
+here you build the kitchen end of a brand-new order. The file is already created, stubbed, and
+wired into `index.ts`, so it's *live right now* β
+`curl http://localhost:3000/api/public/stats` returns `{"pastEventCount":0}`. Your job is to
+make that number honest.
+
+**Goal:** make `GET /api/public/stats` return the real number of past events.
+
+**A gentle way in:**
+1. Open `src/server/routes/public.ts` and find the `/public/past` handler. It already asks the
+ pantry for exactly the rows you care about (published **and** archived events). You're going
+ to reuse that same `.where(...)` filter.
+2. In `stats.ts`, run that query and return the *count* rather than the list. The simplest
+ version: fetch the rows and return `rows.length`. (Uncomment the imports at the top of the
+ file as you reach for them.)
+3. No restart needed β `npm run dev` reloads on save. Re-run the `curl` and watch the number
+ change. That instant feedback loop is the fun part.
+
+**You'll know it worked:** after `npm run db:seed` there are 2 past events, so a correct
+version returns `{"pastEventCount":2}` instead of `0`.
+
+**And here's the whole point** β go back to the dining room and finish the circle: add an
+`api.stats()` function to `web/src/api.ts` (the waiter learns a new order) and show
+"N hackathons and counting" somewhere on your redesigned landing page. That's one small
+feature you built through *every* layer β pantry, kitchen, waiter, dining room. Once you've
+done that, none of this is magic anymore. π
+
+> Want more? Stretch goal: also return `publishedEventCount` (published but not archived). And
+> if you'd like to see the full "add a new column" ritual from Β§3 for real, grab me β it's a
+> great next exercise.
+
+---
+
+## 7. What to leave alone for now
+
+While you're finding your feet, steer clear of these unless we're pairing on it. They have
+sharp edges and real-world consequences:
+
+- **`tickets/sync.ts` and the safety gate** β get this wrong and real people get locked out.
+- **`auth/`** β we never build authentication; mac-auth owns it entirely.
+- **Anything that writes to `audit_log`, or that hard-deletes a row** β please don't add hard
+ deletes; it breaks a promise the whole system relies on.
+- **`db/schema.ts` migrations against production data.**
+
+Adding **read-only** endpoints β exactly like the stats exercise β is always safe. That's the
+corner of the kitchen to play in first.
+
+---
+
+That's the whole machine, both halves. You came in as "the frontend person" and now you can
+read a request from the button a guest clicks all the way down to the pantry shelf and back.
+That's genuinely full-stack β well done.
+
+Any question, however small, come find me. Welcome to the kitchen. π
diff --git a/FRONTEND_GUIDE.md b/FRONTEND_GUIDE.md
new file mode 100644
index 0000000..72dc1fc
--- /dev/null
+++ b/FRONTEND_GUIDE.md
@@ -0,0 +1,339 @@
+# Frontend guide β start here (Part 1 of 2)
+
+> Haven't read **[`PROJECT_OVERVIEW.md`](./PROJECT_OVERVIEW.md)** yet? Do that first (10 min,
+> no typing) β it's the big-picture map. This guide is the hands-on follow-up.
+
+Hey! π Welcome to the MAC Hackathon platform. You're taking over the **frontend** β the part
+people actually see, click, and (fingers crossed) enjoy using. This guide gets you from
+"I've just cloned this repo" to "I understand how it works and I've changed real code," and
+then turns you loose on the redesign.
+
+You genuinely don't need to know the backend to make the frontend beautiful. But I don't want
+any of this to feel like magic you're afraid to touch, so this guide explains the whole shape
+of the thing, and its companion β **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** (Part 2) β walks
+the other half when you're curious. Read them in order; Part 2 literally picks up the story
+where this one leaves off.
+
+Here's the plan for your first day or two:
+1. Read Β§1βΒ§2 to get the mental model (10 minutes, no typing).
+2. Get it running on your laptop (Β§4).
+3. Do the two small **exercises** (Β§6) β this is where it clicks.
+4. Start redesigning (Β§7).
+
+> **Brand new to git?** Skip to the **cheat-sheet in Β§8** and read that first β it's the one
+> tool you'll use constantly, and a little confidence there makes everything else calmer.
+
+---
+
+## 1. The big picture (a restaurant)
+
+The single most useful thing to understand up front: **the website doesn't make up its own
+data.** Every price, name, and date on the screen came from somewhere else and travelled to
+the browser. Once you can picture that journey, every page makes sense.
+
+The easiest way to hold it in your head is a restaurant:
+
+- **The dining room** is the **frontend** (React, in `web/`) β the tables, the menus, the
+ stuff guests see and touch. **This is your patch.**
+- **The waiter** is one small file, **`web/src/api.ts`** β they carry your order to the
+ kitchen and bring the food back. Guests don't wander into the kitchen themselves.
+- **The kitchen** is the **backend** (Express, in `src/server/`) β it does the actual work.
+ Guests never go in, but every dish comes from there.
+- **The pantry** is the **database** (Postgres) β stocked shelves the kitchen cooks from.
+ It's right there, so it's fast.
+- **The suppliers** are **Notion** and **Humanitix** β they deliver fresh stock on a
+ schedule. The kitchen keeps the pantry stocked so it never has to phone a supplier in the
+ middle of dinner service.
+
+Drawn out, a plate of data travels like this:
+
+```
+ Notion (a shared doc) Humanitix (ticket sales) Organisers (admin panel)
+ prizes, judges, FAQ, who bought a ticket create the event,
+ schedule, sponsors⦠for the hackathon press "sync now"
+ β β β
+ β delivered on a timer β delivered on a timer β saved directly
+ βΌ βΌ βΌ
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β THE PANTRY β Postgres (our database) β
+ β one fast, local copy of everything the site needs β
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β²
+ β the KITCHEN reads the pantry and plates up JSON
+ β
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β THE KITCHEN β backend API (Express, src/server/) β
+ β e.g. GET /api/public/event β { event: {β¦}, content: {β¦} } β β Part 2
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β²
+ β the WAITER (web/src/api.ts) carries the order and brings JSON back
+ β
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β THE DINING ROOM β frontend (React, web/) β YOU ARE HERE β
+ β turns JSON into buttons, cards, and text on the page β
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+```
+
+Why keep a *copy* in the pantry instead of asking Notion every time someone loads the page?
+Same reason a kitchen keeps stock: it's faster, and if a supplier's truck is late (Notion is
+down), you can still serve dinner from what's on the shelf. That "copy it on a schedule" job
+is the backend's world β it's the whole second half of the story, so don't worry about it yet.
+
+**The one thing to take away:** by the time data reaches your React code, you don't care
+whether it started in Notion or Humanitix. It's just JSON, handed to you by the waiter.
+
+---
+
+## 2. The loop you'll repeat all day
+
+Almost every screen you build is the same four steps. Learn this rhythm once and the rest is
+detail:
+
+```
+1. A page loads βββΆ 2. it asks the waiter for data (a function in web/src/api.ts)
+ β
+ βΌ
+ 3. the waiter fetches it from the kitchen ( /api/β¦ )
+ β
+ βΌ kitchen reads the pantry, returns JSON
+4. the page saves that JSON and draws it on screen ββββββββββββββββ
+```
+
+When the user *changes* something β joins a team, ticks a box β it's the same loop with one
+extra beat: **send the change, then ask for the data again** so the screen matches reality.
+(That "ask again" habit saves you a world of confusing bugs. More on it in Exercise 2.)
+
+**One rule that matters:** never call `fetch("/api/β¦")` straight from a page. All the kitchen
+orders live in one place β **`web/src/api.ts`**, our waiter β as tidy named functions like
+`api.pastEvents()`. Your pages call *those*. It keeps every network detail (the URL, the
+sign-in token, error handling) in one file instead of scattered everywhere. Need something the
+waiter doesn't offer yet? Add the function to `api.ts` first, then use it in your page.
+
+> That `api.ts` file is exactly the seam where this guide hands off to Part 2: the waiter is
+> the last thing on *your* side of the kitchen door. What happens after they push through it
+> is the backend guide's job.
+
+---
+
+## 3. The files you'll live in
+
+Everything you own is under `web/`. You can happily ignore `src/server/` for now β that's the
+kitchen, and Part 2 gives you the tour.
+
+```
+web/
+ index.html the single HTML page everything loads into
+ src/
+ main.tsx the ROUTER: which URL shows which page. A good first read.
+ api.ts the WAITER: every call to the backend lives here
+ auth.ts sign-in / token plumbing β you'll rarely touch this
+ format.ts date/time helpers (fmtDateRange, fmtTime)
+ styles.css Tailwind theme: our colours + shared classes (buttons, cardsβ¦)
+ pages/
+ Landing.tsx public homepage β WORKS β your best worked example
+ Dashboard.tsx "am I in the hackathon?" β WORKS β the read-and-write example
+ Admin.tsx organiser control panel β WORKS
+ Past.tsx archive of old events π§ EXERCISE 1 (stubbed for you)
+ FindTeam.tsx the teammate pool π§ EXERCISE 2 (stubbed for you)
+ components/
+ TopNav.tsx the shared top bar (Dashboard + Team tabs) on every page
+ SignInPanel.tsx the "please sign in" box
+ ClaimForm.tsx ticket-claiming form
+ CustomFieldsForm.tsx extra event questions
+ TeamPanels.tsx the team views (your team, invites, the pool)
+```
+
+The two π§ pages have been **deliberately hollowed out into guided stubs** β you're going to
+rebuild them, and that's how the whole loop from Β§2 stops being theory. The β pages are
+finished and working, and they're your safety net: whenever you're unsure how to do
+something, open `Landing.tsx` or `Dashboard.tsx` and copy how *they* did it. Reading working
+code is not cheating β it's most of the job.
+
+---
+
+## 4. Getting it running
+
+You'll need [Node 22](https://nodejs.org) and
+[Docker Desktop](https://www.docker.com/products/docker-desktop/) installed (Docker just runs
+the pantry β the Postgres database β so you don't have to install it by hand). Then, from the
+project folder:
+
+```bash
+npm install # once β downloads the project's dependencies
+cp .env.example .env # then open .env and set two things:
+ # DATABASE_URL=...@localhost:5433/mac_hackathon
+ # DEV_AUTH=1
+docker compose up -d db # start just the database (in Docker)
+npm run db:migrate # create the empty tables
+npm run db:seed # stock the pantry with sample data (see the note below)
+npm run dev # start the kitchen (:3000) and the dining room (:5173)
+```
+
+Now open **http://localhost:5173**. That's the Vite dev server, and its superpower is
+**hot reload**: save a `.tsx` file and the page updates in the browser instantly, no refresh.
+This is where you'll spend all your time.
+
+**A fresh database is empty**, so without that `npm run db:seed` step the pages look blank β
+which is expected, not a bug you caused. The seed command stocks the pantry with realistic
+sample data (one upcoming event, two past ones, plus prizes/judges/schedule/FAQ) so every
+page has something to show and to restyle. It's safe to re-run any time.
+
+**Signing in on your laptop.** Real sign-in only works on the live `monashcoding.com` site, so
+locally there's a stand-in: because you set `DEV_AUTH=1`, a little dev sign-in panel lets you
+type any name/email (and tick "organiser" if you want to see the admin pages). This shortcut
+only exists in dev β it physically can't be switched on in production, so don't worry about
+it leaking.
+
+**Your two best friends when something looks broken:**
+- **Browser DevTools** (press F12). The **Console** tab shows React errors in red; the
+ **Network** tab lets you watch each `/api/β¦` call and click it to see exactly what JSON came
+ back. When a page misbehaves, look here *first* β it usually tells you whether the problem
+ is your React or the data it received.
+- **http://localhost:3000/api/health** should say `{"status":"ok"}`. If it does, the kitchen
+ is alive and the problem is on your side of the door.
+
+---
+
+## 5. Styling: Tailwind
+
+We style with **Tailwind CSS**. Instead of writing a separate stylesheet, you put small
+utility classes right on the element:
+
+```tsx
+
β¦
+// ^rounded ^a border ^our bg colour ^padding
+```
+
+Our brand colours live in one place β `web/src/styles.css` β as named tokens you can use
+anywhere: `bg-bg`, `bg-panel`, `text-text`, `text-muted`, `text-accent`, `border-border`,
+`text-danger`, `text-ok`. So `text-accent` is our MAC yellow, `bg-panel` is the card background, and
+so on. Using the tokens (instead of hard-coding a colour) keeps the whole site consistent and
+makes a future theme change a one-file edit.
+
+That same file defines a few **shortcut classes** built from those utilities β `.wrap` (a
+centered page column), `.panel` (a card), `.muted` (grey sub-text), `.topnav`, `.btn`,
+`.card`. You'll spot them all over the existing pages. Redesigning them is fair game β making
+it look good is literally your job β but they're a comfortable place to start.
+
+New to Tailwind? The [docs](https://tailwindcss.com/docs) have a search box β type what you
+want ("padding", "flex", "rounded corners") and it shows you the class. You'll memorise the
+common ones within a week.
+
+---
+
+## 6. Your two exercises (do these before redesigning)
+
+These are small on purpose. They walk you through the whole Β§2 loop on real code, so that by
+the end you're not *reading* about how the app works β you've done it. Both files are already
+open-able with detailed comments inside; this section is the friendly version.
+
+### Exercise 1 β the Past Events page (`web/src/pages/Past.tsx`)
+
+**What you're building:** a read-only page that lists past hackathons. No writing data yet,
+just fetching and drawing β the gentlest possible version of the loop.
+
+The waiter already knows this order: `api.pastEvents()` hands you `{ events: [...] }` (or
+`null` if there aren't any). Your job is the React around it: fetch when the page loads, show
+a "Loadingβ¦" line while you wait, then map over the events and draw each one (name; dates via
+the `fmtDateRange` helper; venue; tagline; a Devpost link if there is one).
+
+**A gentle way in:**
+1. Open `web/src/pages/Landing.tsx` and look at its shape β a `useState` to hold the data, a
+ `useEffect` that calls the waiter once when the page loads, and some JSX that draws the
+ result. That shape *is* the trick. You're copying it.
+2. Peek at `api.pastEvents()` and the `PublicEvent` type in `web/src/api.ts` so you know what
+ fields you're getting (hover them in your editor β the types tell you).
+3. Build it, handling three moments: still loading, loaded-but-empty, and loaded-with-events.
+ Real pages always think about all three.
+
+**You'll know it worked** when the seeded past events show up at `/past` in your browser. π
+
+### Exercise 2 β the Find-a-Team page (`web/src/pages/FindTeam.tsx`)
+
+**What you're building:** a page that both *reads and writes*. This is the real skill, and
+it's the boss level of the loop β take your time.
+
+It shows a pool of people looking for a team, lets you tick "I'm looking for a team" (which
+**saves** to the server), and β if you lead a team with a spare seat β lets you invite
+someone from the pool.
+
+**A gentle way in:**
+1. This time, read `web/src/pages/Dashboard.tsx` as your model. It does the full dance:
+ **load β let the user do something β send the change β ask for the data again.**
+2. The waiter already has every order you need (they're listed in the stub's comments):
+ `api.findTeam()` to load, `api.updateProfile(...)` to opt in/out, `api.inviteFromPool(...)`
+ to invite.
+3. Two things that trip everyone up the first time β and how the reference page handles them:
+ - **Not signed in?** `api.findTeam()` throws a `NotSignedInError`. Catch it and show the
+ `` component instead of letting the page crash.
+ - **After you save a change, re-fetch.** Resist the urge to hand-edit the on-screen data to
+ match what you just sent. Just call your load function again and let fresh data redraw the
+ page. It's less code and it's never wrong.
+
+**You'll know it worked** when ticking the box and reloading keeps it ticked (proof it saved),
+and the pool updates after you invite someone.
+
+> **Stuck, and want to see how it's done?** The original working versions of both pages are
+> still in git on the `mac-hackathon-mvp` branch. Have a real go first β struggling for a bit
+> is where the learning happens β but when you want to check your thinking:
+> `git show mac-hackathon-mvp:web/src/pages/Past.tsx`. Reading a solution *after* you've
+> attempted it is a genuine skill; that's the way to use it.
+>
+> **Curious what happens after the waiter disappears into the kitchen?** That exact question
+> is Part 2 β **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** β and it has a matching little
+> exercise that builds the *other* end of an `api.β¦` call.
+
+---
+
+## 7. The redesign
+
+Once those two work, you understand the frontend β really. Now go make it lovely. A sensible
+order:
+
+1. **`Landing.tsx`** first β the public homepage. Most eyes land here, and it's the most fun
+ to design.
+2. **`Dashboard.tsx`** next β but read the comment at the top of that file before you start.
+ It's the most important page in the whole app: a participant should never leave it unsure
+ whether they're actually in the hackathon. Restyle the *look* all you like; keep every
+ piece of *information* it currently shows.
+3. Design **mobile-first** β a lot of people open this on their phone between classes.
+
+The golden rule: change how a page *looks*, not what it *says*. If you find yourself wanting
+data that isn't there, that's a backend change β jot it down and talk to me rather than faking
+it in the frontend. (And if you're curious how you'd add it yourself, that's Part 2. π)
+
+---
+
+## 8. Git cheat-sheet (if this is new)
+
+You're working on a branch called `frontend-redesign` β think of it as your own copy of the
+project where you can experiment freely without breaking anyone else's work. The everyday
+rhythm:
+
+```bash
+git status # what have I changed?
+git add -A # stage all my changes, ready to save
+git commit -m "Rebuild Past page" # save a snapshot, with a short message
+git push # upload your branch to GitHub
+```
+
+**Commit little and often** β every time something works, save it. Future-you will thank
+present-you. Good messages say what you did ("Add loading state to Find a Team"), not "stuff"
+or "wip".
+
+When a piece of work is ready for me to look at, open a **Pull Request** on GitHub from your
+branch β that's the "hey, please review this" button. Don't commit straight to the main
+branch.
+
+And if you ever end up in a tangle: **stop, don't force anything, and ask.** Almost nothing in
+git is truly unrecoverable, but the fixes are far easier *before* trying random commands you
+found online. Getting stuck is completely normal β reaching out early is the pro move, not the
+beginner one.
+
+---
+
+That's everything you need to start. When you're comfortable here and want to see the other
+half of the machine, **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** is waiting.
+
+Any questions at all, ask me β no question is too small. Have fun with it; real people are
+going to use what you build. π
diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md
new file mode 100644
index 0000000..2c6bf6c
--- /dev/null
+++ b/PROJECT_OVERVIEW.md
@@ -0,0 +1,184 @@
+# Project overview β read this first πΊοΈ
+
+Welcome to the MAC Hackathon platform! This is the **10-minute, no-typing** orientation:
+what this website is, why it exists, how it works at a high level, the rules you must not
+break, and β most importantly β **what you should actually do next**. Once you've read this,
+the hands-on guides ([`FRONTEND_GUIDE.md`](./FRONTEND_GUIDE.md) and
+[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)) take over and get you writing real code.
+
+You don't need to memorise any of this. Just come away with the *shape* of the thing.
+
+---
+
+## 1. What is this website?
+
+It's two things bolted together:
+
+1. **A public info site** for a MAC hackathon β prizes, judges, schedule, sponsors, FAQ.
+ A non-developer edits it in **Notion**, and it updates on the live site with no deploy.
+2. **A team registration system** whose one defining feature is: **every registered
+ participant is checked against a real, paid ticket** (bought on **Humanitix**, the
+ ticketing site).
+
+That second part is the whole point. Hold onto it.
+
+### Why it exists β the two problems
+
+Straight from the Hackathon Director:
+
+- **"We don't know if these people actually exist until the very end."** Teams sign up
+ without their whole team, or with people who never bought a ticket. Nobody notices the
+ gaps until event day.
+- **"People keep asking if they're even in the hackathon."** Someone buys a ticket, hears
+ nothing back, and DMs the director to ask if they're registered.
+
+Both have the *same* root cause: **the ticket and the team roster are two separate lists
+with no link between them.** This website links them. The ticket becomes the source of
+truth, and the team roster is forced to constantly reconcile against it. That's the entire
+idea in one sentence.
+
+### What this is NOT (so you don't build the wrong thing)
+
+- β **Not** project submissions or judging β that stays on **Devpost**.
+- β **Not** ticket sales or payments β that's **Humanitix**.
+- β **Not** an email system β MAC deliberately doesn't send email. Notifications happen
+ **in-app** and via **Discord**.
+- β **Not** a general CMS β **Notion** is the CMS.
+
+If you ever catch yourself building one of these, stop β it belongs somewhere else.
+
+---
+
+## 2. How it works, at a high level
+
+The single most useful idea: **the website never makes up its own data.** Everything on the
+screen came from somewhere else and travelled to the browser. There are three "somewhere
+else"s:
+
+```
+ Notion Humanitix Organisers
+ (the info: prizes, (who bought a (create the event and
+ judges, schedule) ticket) press "sync now" in /admin)
+ β β β
+ β copied on a β copied on a β saved directly
+ β timer β timer β
+ βΌ βΌ βΌ
+ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β OUR DATABASE (Postgres) β one fast, local copy of β
+ β everything the site needs β
+ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β
+ β the backend reads the database and hands out JSON
+ βΌ
+ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β THE WEBSITE (React) β turns JSON into pages people see β
+ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+```
+
+Why keep our own *copy* instead of asking Notion/Humanitix live on every page load? Speed,
+and safety: if Notion is down, we can still serve the page from our copy. Keeping the copy
+fresh (on a timer) is the backend's job β you'll meet it in Part 2.
+
+**The takeaway:** by the time data reaches the React code you'll be editing, you don't care
+where it started. It's just JSON.
+
+### The three big ideas layered on top
+
+1. **Verification.** A participant signs in (with their Monash account), and we match them
+ to their Humanitix ticket β by email, or by them typing in their **order reference**
+ (the short `7QVD6HEL`-style code from their confirmation email) plus surname. Once
+ matched, they see an unambiguous **"you're registered"** state and stop DMing anyone.
+2. **Teams.** Verified people form teams (2β4 people). A team lead can see, at any time,
+ exactly which members haven't **accepted** the invite and which haven't **bought a
+ ticket** β weeks early, not on the day.
+3. **The organiser gap report.** The admin view lists every ticket-holder with no team,
+ every team member with no ticket, and every unaccepted invite. This one screen is the
+ thing the director used to rebuild by hand β it's *why this project exists*.
+
+---
+
+## 3. The pages
+
+Two pages for participants, split by the question each one answers:
+
+- **`/dashboard` β "Am I actually in the hackathon?"** Ticket state, the verify/claim flow,
+ and personal details. Nothing else. If someone reads this page and still has to ask, the
+ page has failed.
+- **`/find-team` (the "Team" tab) β everything about teams.** Your team, who hasn't accepted
+ or bought a ticket yet, invites, team questions, and the pool of people looking for a
+ team. All of it is locked until you're verified.
+
+Plus **`/admin`** for organisers β the team board and the gap report above. It's reachable
+by URL but deliberately not shown in the nav.
+
+---
+
+## 4. The rules you must not break
+
+These aren't style preferences β they're load-bearing. Most were learned the hard way.
+
+- **Humanitix is read-only.** We only ever *read* ticket data. Never write to it.
+- **No email, ever.** No SMTP, no "send a confirmation email". In-app state + Discord.
+- **We never build auth.** Sign-in is handled by `mac-auth`, a separate MAC service. Don't
+ reinvent it.
+- **Everything must survive handover.** The committee changes every year. No personal
+ accounts, no paid subscriptions that can lapse, and next year's team must be able to run
+ the whole thing from a clean clone with one command β *without talking to whoever built
+ it.* If a decision relies on someone *remembering* something, it's the wrong decision.
+- **Nothing is truly deleted.** Things get marked as gone and preserved, so mistakes are
+ recoverable.
+
+The full, authoritative version of all of this lives in
+**[`SPEC_hackathon.md`](./SPEC_hackathon.md)** β that document is the source of truth. You
+don't need to read all 593 lines today, but know it's there when a "can I...?" question
+comes up.
+
+---
+
+## 5. What YOU should do (your first week)
+
+Here's the path. Don't skip steps β each one makes the next make sense.
+
+1. **Read this page** (done! β ) so you have the mental model.
+2. **Get it running on your laptop.** Full instructions are in
+ [`FRONTEND_GUIDE.md`](./FRONTEND_GUIDE.md) Β§4 β it's a handful of copy-paste commands and
+ a sample-data seed so the pages aren't blank.
+3. **Do the two exercises** in the frontend guide (Β§6). Two pages β
+ [`Past.tsx`](./web/src/pages/Past.tsx) and [`FindTeam.tsx`](./web/src/pages/FindTeam.tsx) β
+ have been **deliberately hollowed out** for you to rebuild. This is where everything
+ clicks from "reading about it" to "I did it." The finished versions of both pages exist
+ on the `mac-hackathon-mvp` branch if you get stuck and want to peek *after* trying.
+4. **Then start the redesign** (frontend guide Β§7) β make it look great. The golden rule:
+ change how a page *looks*, not what it *says*. If a page is missing data you wish it had,
+ that's a backend change β write it down and ask, don't fake it in the frontend.
+5. **Curious about the other half?** [`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md) (Part 2) walks
+ the server side and has its own small exercise. Optional, but it demystifies the whole
+ machine.
+
+### A few practical notes
+
+- **Where you'll live:** everything you own is under `web/`. You can ignore `src/server/`
+ (the backend) until you're curious.
+- **Your branch:** you're on `frontend-redesign` β your own safe copy. Commit little and
+ often, push, and open a Pull Request when something's ready for review. Never commit
+ straight to `main`. (Git cheat-sheet: frontend guide Β§8.)
+- **Seeing your work live:** this branch auto-deploys to
+ **[staging-hackathons.monashcoding.com](https://staging-hackathons.monashcoding.com)** β a
+ throwaway preview with sample data, completely separate from the real site. Push, wait a
+ minute, and your changes are there to show people.
+- **When you're stuck:** that's normal and expected β reaching out early is the pro move,
+ not the beginner one. No question is too small.
+
+---
+
+## 6. The map of docs
+
+| File | What it's for |
+|------|---------------|
+| **`PROJECT_OVERVIEW.md`** (this file) | The 10-minute big picture. Start here. |
+| [`FRONTEND_GUIDE.md`](./FRONTEND_GUIDE.md) | Part 1 β how the frontend works + your two exercises. **Your main guide.** |
+| [`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md) | Part 2 β the server side, when you're curious. |
+| [`SPEC_hackathon.md`](./SPEC_hackathon.md) | The full source-of-truth spec. Reference, not bedtime reading. |
+| [`README.md`](./README.md) | Setup, deployment, and stack details for the whole repo. |
+
+Welcome aboard β real people are going to use what you build. Have fun with it. π
diff --git a/README.md b/README.md
index 95dbf29..9d731e2 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,15 @@ MAC's hackathon platform: a public info site (Notion-driven) plus team registrat
Read [`SPEC_hackathon.md`](./SPEC_hackathon.md) β it is the source of truth. This README
covers running the thing.
+> **Brand new here?** Read **[`PROJECT_OVERVIEW.md`](./PROJECT_OVERVIEW.md)** first β a
+> 10-minute, no-typing big-picture map of what this is, how it works, and what to do next.
+>
+> **New here / redesigning the frontend?** There's a two-part, beginner-friendly walkthrough
+> that reads as one story. Start with **[`FRONTEND_GUIDE.md`](./FRONTEND_GUIDE.md)** (Part 1) β
+> how the frontend works, how it talks to the backend, and two hands-on exercises β then
+> **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** (Part 2) picks up where it leaves off and walks
+> the server side (Express + Drizzle + Postgres) with a matching exercise.
+
## Stack
Node 22 Β· TypeScript Β· Express Β· React + Vite (built, served same-origin by Express) Β·
diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml
new file mode 100644
index 0000000..3d01be5
--- /dev/null
+++ b/docker-compose.staging.yml
@@ -0,0 +1,82 @@
+# Staging compose for Dokploy β a persistent PREVIEW of the frontend-redesign
+# branch at https://staging-hackathons.monashcoding.com, kept completely separate
+# from production. See docs/deploy-dokploy.md β "Staging preview".
+#
+# NOTE ON THE HOSTNAME: this is a SINGLE-level subdomain on purpose. The domain
+# sits behind Cloudflare, whose free Universal SSL cert covers monashcoding.com
+# and *.monashcoding.com β but NOT a nested *.hackathons.monashcoding.com. A host
+# like staging.hackathons.monashcoding.com would fail the TLS handshake at
+# Cloudflare's edge (ERR_SSL_VERSION_OR_CIPHER_MISMATCH) before ever reaching
+# Traefik. Keeping it one level deep means Cloudflare serves TLS with no extra
+# cert and no paid Advanced Certificate Manager to remember/renew.
+#
+# Differences from docker-compose.dokploy.yml (production):
+# β’ Its own domain + Traefik router/service NAMES (must be unique on the host).
+# β’ Its own Postgres + db_data volume β Dokploy scopes these per compose stack,
+# so staging data never mixes with production.
+# β’ ALLOW_SEED=1 β the entrypoint auto-seeds demo content on boot, so the
+# preview is never blank. Real production never sets this.
+# β’ No Humanitix / Notion keys β staging shows seeded content, touches nothing
+# real. (Add them later only if you deliberately want live sync in staging.)
+#
+# Still NODE_ENV=production: that's what makes the app serve the built SPA. It is
+# "production mode running throwaway data", not a dev server.
+services:
+ db:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: mac_hackathon
+ POSTGRES_PASSWORD: mac_hackathon
+ POSTGRES_DB: mac_hackathon
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U mac_hackathon -d mac_hackathon"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
+ app:
+ build: .
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ DATABASE_URL: postgres://mac_hackathon:mac_hackathon@db:5432/mac_hackathon
+ NODE_ENV: production
+ PORT: "3000"
+ PUBLIC_URL: https://staging-hackathons.monashcoding.com
+
+ # Auto-seed demo content on every deploy (idempotent). Staging only.
+ ALLOW_SEED: "1"
+
+ # mac-auth is shared with production. Public pages (landing, past) work
+ # without sign-in; auth-gated pages (dashboard/find-team/admin) only work
+ # if this origin is in mac-auth's TRUSTED_ORIGINS β see the runbook note.
+ MAC_AUTH_URL: https://auth.monashcoding.com
+ MAC_AUTH_JWKS_URL: https://auth.monashcoding.com/api/auth/jwks
+ JWT_AUDIENCE: mac-suite
+ ORGANISER_ROLES: ${ORGANISER_ROLES:-committee,exec,admin}
+
+ # Deliberately no HUMANITIX/NOTION keys β staging never hits real services.
+ networks:
+ - default # reach the db
+ - dokploy-network # be reachable by Dokploy's Traefik
+ # Router/service names are SUFFIXED "-staging" so they never collide with the
+ # production stack's Traefik config on the same host.
+ labels:
+ - traefik.enable=true
+ - traefik.docker.network=dokploy-network
+ - traefik.http.routers.mac-hackathon-staging.rule=Host(`staging-hackathons.monashcoding.com`)
+ - traefik.http.routers.mac-hackathon-staging.entrypoints=websecure
+ - traefik.http.routers.mac-hackathon-staging.tls.certresolver=letsencrypt
+ - traefik.http.services.mac-hackathon-staging.loadbalancer.server.port=3000
+
+networks:
+ dokploy-network:
+ external: true
+
+volumes:
+ db_data:
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 7e3c6e5..ca88c4a 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -8,5 +8,14 @@ set -e
echo "[entrypoint] running migrationsβ¦"
npm run db:migrate
+# Staging-only: auto-populate demo content on boot so the preview site isn't
+# blank. Gated behind ALLOW_SEED=1 (set only on the staging service) β the seed
+# script itself also refuses to run in production without it, so real production
+# is never touched. Idempotent (upserts), so re-seeding every deploy is fine.
+if [ "$ALLOW_SEED" = "1" ]; then
+ echo "[entrypoint] ALLOW_SEED=1 β seeding demo contentβ¦"
+ npm run db:seed
+fi
+
echo "[entrypoint] starting serverβ¦"
exec npm start
diff --git a/docs/deploy-dokploy.md b/docs/deploy-dokploy.md
index bd4a05a..b9ed3a6 100644
--- a/docs/deploy-dokploy.md
+++ b/docs/deploy-dokploy.md
@@ -100,6 +100,51 @@ Then check:
---
+## Staging preview (persistent link for frontend work)
+
+A **separate, throwaway** Dokploy service that runs the `frontend-redesign` branch at
+`https://staging-hackathons.monashcoding.com`, so work-in-progress can be previewed on a
+real URL **without ever touching production**. It uses its own compose file
+([`docker-compose.staging.yml`](../docker-compose.staging.yml)), its own database, and
+auto-seeds demo content on boot β so it's never blank and never hits Humanitix/Notion.
+
+> **Why `staging-hackathons` and not `staging.hackathons`?** The domain sits behind
+> Cloudflare, whose free Universal SSL cert covers `monashcoding.com` and `*.monashcoding.com`
+> but **not** a nested `*.hackathons.monashcoding.com`. A host like
+> `staging.hackathons.monashcoding.com` fails the TLS handshake at Cloudflare's edge
+> (`ERR_SSL_VERSION_OR_CIPHER_MISMATCH`) before it ever reaches Traefik. Keeping the preview a
+> **single-level** subdomain means Cloudflare serves TLS automatically β no paid Advanced
+> Certificate Manager to remember or renew.
+
+1. **DNS (once).** In Cloudflare, add a record for `staging-hackathons.monashcoding.com`
+ pointing at the same Oracle VM as production (mirror however the prod `hackathons` record is
+ set up β same target, **proxied / orange cloud on**). Because it's one level deep, Universal
+ SSL covers it with no extra certificate.
+2. **Create a second Compose service** in Dokploy (name it e.g. `hackathons-staging`):
+ - Provider = GitHub, repo `monashcoding/hackathons`, branch **`frontend-redesign`**.
+ - **Compose Path**: `docker-compose.staging.yml`.
+3. **Domain**: add `staging-hackathons.monashcoding.com`, HTTPS on, Let's Encrypt, port 3000.
+4. **Environment**: none required β the staging compose hard-sets everything, including
+ `ALLOW_SEED=1` (which makes the entrypoint seed demo content on every deploy) and
+ `NODE_ENV=production` (so the built SPA is served). Leave the env box empty.
+5. **Auto-deploy on push**: enable Dokploy's native **Auto Deploy** on this staging service.
+ Then every push to `frontend-redesign` redeploys the preview automatically. (The CI-gated
+ `deploy.yml` only fires for the production branch, so it won't interfere.)
+6. **Deploy.** Watch the logs for `ALLOW_SEED=1 β seeding demo contentβ¦` then the usual
+ `listening on :3000`. Visit the staging URL.
+
+> **Public pages just work** on staging (landing, `/past`) β no sign-in needed, so that's the
+> whole redesign surface covered. The **auth-gated pages** (`/dashboard`, `/find-team`,
+> `/admin`) additionally need `staging-hackathons.monashcoding.com` added to **mac-auth's**
+> `TRUSTED_ORIGINS`; until then they'll fail to sign in on staging (they still work locally
+> via dev sign-in). Ask whoever administers mac-auth if she needs those pages live.
+
+**Tearing it down** when the redesign lands: delete the staging service in Dokploy and remove
+the `staging-hackathons` DNS record. `docker-compose.staging.yml` can stay in the repo for
+next time.
+
+---
+
## Operating notes
- **The database volume `db_data` is the only stateful thing.** Back it up before major
diff --git a/package.json b/package.json
index 6b813a8..3defa7e 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"start": "tsx src/server/index.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx --env-file-if-exists=.env src/server/db/migrate.ts",
+ "db:seed": "tsx --env-file-if-exists=.env src/server/db/seed.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts
new file mode 100644
index 0000000..2d0caa4
--- /dev/null
+++ b/src/server/db/seed.ts
@@ -0,0 +1,145 @@
+// ---------------------------------------------------------------------------
+// Dev seed β fills an empty local database with a realistic-looking event so the
+// public site, past-events page, and dashboard aren't blank while you work on
+// the frontend.
+//
+// npm run db:seed
+//
+// DEV ONLY. It writes plausible fake data, never touches Notion/Humanitix, and
+// is safe to run repeatedly β it upserts by a stable key (event slug / a fake
+// "notion page id"), so re-running just refreshes the same rows rather than
+// piling up duplicates. It refuses to run when NODE_ENV=production UNLESS
+// ALLOW_SEED=1 is explicitly set β that override is how the throwaway STAGING
+// deploy (which runs NODE_ENV=production so it serves the built SPA) seeds
+// itself. Real production never sets ALLOW_SEED, so its database is safe.
+//
+// This is NOT how real content gets in β that comes from Notion via the sync
+// (see BACKEND_GUIDE.md Β§4). This is scaffolding so you have something to style.
+// ---------------------------------------------------------------------------
+import { db, closeDb } from "./index.ts";
+import { contentBlocks, events } from "./schema.ts";
+import { isProduction } from "../env.ts";
+
+async function upsertEvent(row: typeof events.$inferInsert) {
+ const [saved] = await db
+ .insert(events)
+ .values(row)
+ .onConflictDoUpdate({ target: events.slug, set: row })
+ .returning();
+ return saved!;
+}
+
+async function upsertBlock(row: typeof contentBlocks.$inferInsert) {
+ await db
+ .insert(contentBlocks)
+ .values(row)
+ .onConflictDoUpdate({ target: contentBlocks.notionPageId, set: row });
+}
+
+async function main() {
+ if (isProduction && process.env.ALLOW_SEED !== "1") {
+ throw new Error(
+ "Refusing to seed: NODE_ENV=production. Set ALLOW_SEED=1 to override (staging only).",
+ );
+ }
+
+ // --- The current event (published, not archived β this is what the landing
+ // page and dashboard show). Dates are in the near future. ---
+ const current = await upsertEvent({
+ slug: "2026",
+ name: "MACATHON 2026",
+ tagline: "48 hours. One idea. Build something that matters.",
+ startsAt: new Date("2026-09-19T09:00:00+10:00"),
+ endsAt: new Date("2026-09-21T17:00:00+10:00"),
+ venue: "Monash University, Clayton",
+ registrationOpensAt: new Date("2026-08-01T00:00:00+10:00"),
+ registrationClosesAt: new Date("2026-09-15T23:59:00+10:00"),
+ minTeamSize: 2,
+ maxTeamSize: 4,
+ devpostUrl: "https://macathon-2026.devpost.com",
+ isPublished: true,
+ isArchived: false,
+ });
+
+ // --- Past events (published AND archived β these show on /past). ---
+ await upsertEvent({
+ slug: "2025",
+ name: "MACATHON 2025",
+ tagline: "Where it all came together.",
+ startsAt: new Date("2025-09-20T09:00:00+10:00"),
+ endsAt: new Date("2025-09-22T17:00:00+10:00"),
+ venue: "Monash University, Clayton",
+ minTeamSize: 2,
+ maxTeamSize: 4,
+ devpostUrl: "https://macathon-2025.devpost.com",
+ isPublished: true,
+ isArchived: true,
+ });
+ await upsertEvent({
+ slug: "2024",
+ name: "MACATHON 2024",
+ tagline: "The one that started the streak.",
+ startsAt: new Date("2024-09-21T09:00:00+10:00"),
+ endsAt: new Date("2024-09-23T17:00:00+10:00"),
+ venue: "Monash University, Caulfield",
+ minTeamSize: 2,
+ maxTeamSize: 4,
+ isPublished: true,
+ isArchived: true,
+ });
+
+ // --- Content for the current event. In production these rows come from Notion
+ // via the sync; here we fake a handful so every section on the landing
+ // page has something to render. `payload` matches the ContentItem shape
+ // the frontend expects (see web/src/api.ts). ---
+ const blocks: Array<{
+ kind: (typeof contentBlocks.$inferInsert)["kind"];
+ id: string;
+ sort: number;
+ payload: Record;
+ }> = [
+ { kind: "prize", id: "seed-prize-1", sort: 0, payload: { title: "Best Overall", subtitle: "$2,000 + interviews", bodyHtml: "
The team that best nails idea, execution, and demo.
Anyone, from any university. All skill levels welcome.
" } },
+ ];
+
+ for (const b of blocks) {
+ await upsertBlock({
+ eventId: current.id,
+ kind: b.kind,
+ notionPageId: b.id,
+ payload: b.payload,
+ sortOrder: b.sort,
+ isPublished: true,
+ isPresent: true,
+ });
+ }
+
+ console.log(
+ `[seed] done β current event "${current.name}", 2 past events, ${blocks.length} content blocks.`,
+ );
+ console.log("[seed] open http://localhost:5173 (landing) and /past to see it.");
+}
+
+main()
+ .then(() => closeDb())
+ .catch(async (err) => {
+ console.error("[seed] failed:", err);
+ await closeDb();
+ process.exit(1);
+ });
diff --git a/src/server/index.ts b/src/server/index.ts
index 4a9d0d6..a0e78e0 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -6,6 +6,7 @@ import { healthRouter } from "./routes/health.ts";
import { meRouter } from "./routes/me.ts";
import { eventsRouter } from "./routes/events.ts";
import { publicRouter } from "./routes/public.ts";
+import { statsRouter } from "./routes/stats.ts"; // BACKEND_GUIDE.md exercise
import { contentRouter } from "./routes/content.ts";
import { ticketsRouter } from "./routes/tickets.ts";
import { dashboardRouter } from "./routes/dashboard.ts";
@@ -23,6 +24,7 @@ app.use(express.json());
app.use("/api", healthRouter);
app.use("/api", meRouter);
app.use("/api", publicRouter);
+app.use("/api", statsRouter); // BACKEND_GUIDE.md exercise β GET /api/public/stats
app.use("/api", contentRouter);
app.use("/api", dashboardRouter);
app.use("/api/organiser", organiserRouter);
diff --git a/src/server/routes/stats.ts b/src/server/routes/stats.ts
new file mode 100644
index 0000000..3c43e58
--- /dev/null
+++ b/src/server/routes/stats.ts
@@ -0,0 +1,31 @@
+import { Router } from "express";
+// You'll need these once you start writing the real query β uncomment as you go:
+// import { and, eq } from "drizzle-orm";
+// import { db } from "../db/index.ts";
+// import { events } from "../db/schema.ts";
+
+export const statsRouter = Router();
+
+// π BACKEND EXERCISE β see BACKEND_GUIDE.md ("Exercise: your first endpoint").
+//
+// Build a public, read-only endpoint that reports a couple of simple counts the
+// homepage could show off (e.g. "12 past events"). This teaches the whole
+// backend loop end-to-end: a route β a Drizzle query on Postgres β JSON out.
+//
+// Make GET /api/public/stats return something like:
+// { "pastEventCount": 12 }
+//
+// Steps (all the pieces already exist elsewhere in this file tree):
+// 1. Query the `events` table for rows that are published AND archived β that's
+// what "a past event" means. Copy the WHERE clause from the /public/past
+// handler in src/server/routes/public.ts (it does exactly this filter).
+// 2. Count them and return the number as JSON.
+// 3. Keep it PUBLIC β no requireAuth here. Anyone can hit the homepage.
+//
+// Stretch goal: also return `publishedEventCount` (published, not archived).
+//
+// Replace the placeholder below with your real implementation.
+statsRouter.get("/public/stats", async (_req, res) => {
+ // TODO: run the real query and return real numbers.
+ res.json({ pastEventCount: 0 });
+});
diff --git a/web/src/pages/FindTeam.tsx b/web/src/pages/FindTeam.tsx
index e8fe7c2..14dd747 100644
--- a/web/src/pages/FindTeam.tsx
+++ b/web/src/pages/FindTeam.tsx
@@ -1,225 +1,43 @@
-import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
-import { api, NotSignedInError, type DashboardResponse, type FindTeamResponse } from "../api.ts";
-import { SignInPanel } from "../components/SignInPanel.tsx";
-import { TopNav } from "../components/TopNav.tsx";
-import { CustomFieldsForm } from "../components/CustomFieldsForm.tsx";
-import { InvitesPanel, NoTeamPanel, TeamPanel } from "../components/TeamPanels.tsx";
-// The Team page (spec Β§9 + team formation). Everything about teams lives here:
-// your current team (or the create/join controls), pending invites, team
-// questions, and the looking-for-a-team pool. The dashboard is left to the
-// ticket + personal details; this page needs both the dashboard payload (team,
-// invites, custom fields) and the find-team payload (the pool), so it loads
-// both. All team options are gated behind ticket verification.
+// π EXERCISE 2 β see FRONTEND_GUIDE.md ("Exercise 2: the Find-a-Team page").
+//
+// A step up from Exercise 1: this page both READS and WRITES data, so you'll
+// practise the full loop β load data, let the user change something, send that
+// change to the server, then re-load so the screen matches reality.
+//
+// Rebuild it so it:
+// 1. Loads the pool of people looking for a team: api.findTeam()
+// 2. Handles being signed out: if the fetch throws a NotSignedInError,
+// show the component instead of the pool.
+// 3. Has a checkbox to opt in / out of the pool:
+// api.updateProfile({ lookingForTeam: true|false })
+// 4. If the signed-in user leads a team with a free slot, shows an
+// "Invite to my team" button next to each person:
+// api.inviteFromPool(myTeamId, participantId)
+// 5. After ANY write, re-fetches so the UI reflects the new state.
+//
+// Things you'll use (all already built):
+// β’ api.findTeam / updateProfile / inviteFromPool, the FindTeamResponse type,
+// and NotSignedInError β web/src/api.ts
+// β’ β web/src/components/SignInPanel.tsx
+// β’ The "load β mutate β refresh" pattern, done in full:
+// web/src/pages/Dashboard.tsx
+//
+// Delete this placeholder and the TODO note once your version works.
export function FindTeam() {
- const [dash, setDash] = useState(null);
- const [find, setFind] = useState(null);
- const [signedOut, setSignedOut] = useState(false);
- const [error, setError] = useState("");
- const [busy, setBusy] = useState(false);
-
- async function refresh() {
- setError("");
- try {
- const [d, f] = await Promise.all([api.dashboard(), api.findTeam()]);
- setDash(d);
- setFind(f);
- setSignedOut(false);
- } catch (e) {
- if (e instanceof NotSignedInError) {
- setSignedOut(true);
- } else {
- setError((e as Error).message);
- }
- }
- }
- useEffect(() => {
- void refresh();
- }, []);
-
- async function toggleLooking(v: boolean) {
- setBusy(true);
- try {
- await api.updateProfile({ lookingForTeam: v });
- await refresh();
- } finally {
- setBusy(false);
- }
- }
- async function invite(participantId: string) {
- if (!find?.myTeamId) return;
- setBusy(true);
- try {
- await api.inviteFromPool(find.myTeamId, participantId);
- await refresh();
- } catch (e) {
- alert((e as Error).message);
- } finally {
- setBusy(false);
- }
- }
-
- const status = dash?.participant?.verificationStatus;
- const verified = status === "verified" || status === "override";
-
return (
-
-
-
Team
-
- Form your team, manage members, and browse others looking for teammates. Teams need at
- least 2 people β keep the conversation going in the MAC Discord.
-
-
- {signedOut && }
- {error &&
{error}
}
-
- {dash && dash.event === null && (
-
There's no active hackathon right now.
- )}
-
- {dash && dash.event && !verified && (
-
-
Verify your ticket first
-
- Team registration unlocks once your Humanitix ticket is verified. Head to your{" "}
- dashboard{" "}
- and claim your ticket with your order reference β it takes ten seconds.
-
- );
-}
-
-// Team formation happens in the MAC Discord β this is the one-tap way in. Only
-// rendered when the event has a Discord invite configured (admin UI).
-function DiscordCard({ url }: { url: string | null }) {
- if (!url) return null;
- return (
-
-
- Team up in the MAC Discord
-
- This is where teams actually form β introduce yourself, find teammates, and ask
- organisers anything. There's no chat here, so head to Discord to connect.
+
+
+
Find a team
+
π§ TODO: build this page β see FRONTEND_GUIDE.md (Exercise 2).
);
}
-
-// The looking-for-a-team pool: the browsable list of verified, teamless
-// participants who opted in. When you're teamless it also shows the opt-in
-// toggle (put me in the pool); when you're already in a team that toggle is
-// hidden β you can't be in the pool while teamed β and a lead can invite
-// people straight into their team. NO chat β the conversation is on Discord.
-function Pool({
- find,
- inTeam,
- busy,
- onToggle,
- onInvite,
-}: {
- find: FindTeamResponse;
- inTeam: boolean;
- busy: boolean;
- onToggle: (v: boolean) => void;
- onInvite: (participantId: string) => void;
-}) {
- return (
- <>
- {!inTeam && (
-
-
-
- Opt in and other participants (and team leads with a spare slot) can find you.
-
-
- )}
-
-
-
Looking for a team ({find.pool.length})
- {find.pool.length === 0 && (
-
- {inTeam
- ? "No one's in the pool right now. As participants opt into βlooking for a teamβ they'll show up here and you can invite them straight into your team."
- : "No one else is in the pool yet β you won't see yourself here. Check back as more people opt in, and say hi in the Discord above."}
-
Your team is full β no open slots to invite into.
- )}
-
- >
- );
-}
diff --git a/web/src/pages/Past.tsx b/web/src/pages/Past.tsx
index cd9e0bf..d7ec95d 100644
--- a/web/src/pages/Past.tsx
+++ b/web/src/pages/Past.tsx
@@ -1,35 +1,36 @@
-import { useEffect, useState } from "react";
-import { api, type PublicEvent } from "../api.ts";
-import { TopNav } from "../components/TopNav.tsx";
-import { fmtDateRange } from "../format.ts";
+import { Link } from "react-router-dom";
-// Archive of previous events. Grows for free every year.
+// π EXERCISE 1 β see FRONTEND_GUIDE.md ("Exercise 1: the Past Events page").
+//
+// This is your warm-up: a read-only page. Rebuild it so it:
+// 1. Fetches the list of past events when the page loads.
+// 2. Shows a "Loadingβ¦" message while the request is in flight.
+// 3. Renders each event: name, dates, venue, tagline, and (if present) a
+// link to its Devpost.
+// 4. Says something friendly if there are no past events yet.
+//
+// Everything you need already exists β you are only writing the React part:
+// β’ Data: api.pastEvents() β { events: PublicEvent[] } | null
+// (defined in web/src/api.ts β go read it)
+// β’ Dates: fmtDateRange(startsAt, endsAt)
+// (defined in web/src/format.ts)
+// β’ A page that already does exactly this shape of fetch-then-render:
+// web/src/pages/Landing.tsx β copy this pattern
+//
+// Delete this placeholder and the TODO note once your version works.
export function Past() {
- const [events, setEvents] = useState(null);
-
- useEffect(() => {
- api.pastEvents().then((d) => setEvents(d?.events ?? [])).catch(() => setEvents([]));
- }, []);
-
return (