diff --git a/README.md b/README.md
index 7fc619e6..edaeb8e3 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ Two entities anchor the graph:
`user_account_link`, and `stripe_payment.linked_user_id`.
- **`hackathon`** — every event-scoped table cascades from it: teams,
participants, projects, hackathon events, judges, judge assignments, judging
- projects, judge queue, and maps. `member` is also scoped to a hackathon.
+ projects, judge queue, and maps.
Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a
hackathon removes its dependent rows rather than orphaning them.
@@ -69,14 +69,18 @@ Two aspects share the database and touch nowhere:
- **Hackathon** — editions, registration, teams, project submission, judging.
Everything here hangs off a `hackathon` row.
-- **Club** — `initiative`, its applications, and the `project_leader` role.
- Deliberately **not** scoped to a hackathon. A club project runs whenever
- somebody leads one, and leading is a standing appointment rather than a
- yearly re-grant. Nothing in this half is ever judged; judges only score
- `hackathon_project`.
-
-`member` is the one crossing case: a paid year still hangs off an edition, so
-membership resolves the current hackathon even though initiatives do not.
+- **Club** — `member`, `membership_history`, `event`, `event_check_in`,
+ `initiative`, its applications, and the `project_leader` role. Deliberately
+ **not** scoped to a hackathon. A club project runs whenever somebody leads
+ one, and leading is a standing appointment rather than a yearly re-grant.
+ Nothing in this half is ever judged; judges only score `hackathon_project`.
+
+The two halves no longer cross. `member` used to be `unique(user_id,
+hackathon_id)`, which welded a paid year to an edition: the day the next
+hackathon opened, every paying member read as a non-member. It is now
+`unique(user_id)` and a membership is defined entirely by its own dates, with
+`membership_history` recording which years somebody held one. The club half
+therefore works with no hackathon in the database at all.
#### One-off step — only for a database that already has the edition-scoped tables
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
index 57bb7ed2..430ee700 100644
--- a/packages/api/src/routers/initiative.ts
+++ b/packages/api/src/routers/initiative.ts
@@ -70,14 +70,12 @@ function canManage(
/**
* Applying is a member benefit, so it needs a membership that has not lapsed.
*
- * Initiatives are unscoped but membership is not — a paid year still hangs off
- * an edition, so this resolves the current one. No edition means nobody has a
- * live membership to check, which refuses rather than waving everyone through.
+ * Keyed on the person alone. This used to resolve a "current edition" and look
+ * the membership up against it, so between hackathons — when no edition
+ * qualified — it refused everybody, which is how the club half went dead
+ * outside event season.
*/
async function requireActiveMember(db: Reader, userId: string) {
- // Initiatives were deliberately un-scoped from hackathons; membership now is
- // too. This previously resolved a current edition and refused everyone when
- // none existed, which is how the club half went dead outside event season.
const member = await db.query.members.findFirst({
where: eq(members.userId, userId),
columns: { isActive: true, membershipEndDate: true },
diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
index 7b6a7864..23848239 100644
--- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
@@ -13,7 +13,8 @@ import { AnalyticsTab } from "@/components/admin/hackathons/AnalyticsTab";
import { EventsTab } from "@/components/admin/hackathons/EventsTab";
import { JudgesTab } from "@/components/admin/hackathons/JudgesTab";
import { AnnouncementsTab } from "@/components/admin/hackathons/AnnouncementsTab";
-import { Gavel, Megaphone } from "lucide-react";
+import { TableCards } from "@/components/admin/hackathons/TableCards";
+import { Gavel, Megaphone, QrCode } from "lucide-react";
type Tab =
| "events"
@@ -21,7 +22,8 @@ type Tab =
| "attendees"
| "analytics"
| "judges"
- | "announcements";
+ | "announcements"
+ | "tables";
export default function AdminHackathonDashboard() {
const { status } = useSession();
@@ -59,6 +61,11 @@ export default function AdminHackathonDashboard() {
icon: