Quick reference for common development tasks. Add new sections as patterns emerge.
| Layer | Tech |
|---|---|
| Client | React + TypeScript + Vite |
| Styling | Tailwind CSS v4 |
| State | Redux Toolkit (auth slice) |
| Data fetching | TanStack Query |
| Auth | Clerk |
| Server | Express + TypeScript |
| Database | Neon Postgres + Drizzle ORM |
| Fantasy data | Sleeper (via provider pattern) |
- Always branch off latest
main— never reuse a merged PR branch - Branch naming:
feat/short-description,fix/short-description,chore/... - Commits: Conventional Commits (
feat:,fix:,chore:,refactor:,docs:) - Open a PR for all changes — never commit directly to
mainunless explicitly granted permission for a trivial chore
The dashboard is a newspaper-style layout. Each section lives in its own file under client/src/widgets/dashboard/:
client/src/widgets/dashboard/
├── _shared.tsx # Eyebrow, SectionHead, SortHeader, MatchupResult,
│ # teamName / teamAvatar / ordinal helpers
├── Ticker.tsx # Top scrolling marquee (full bleed, above masthead)
├── Masthead.tsx # Newspaper title block
├── MyTeamSection.tsx # Lead hero with claimed-team summary (private Stat helper)
├── TopPerformers.tsx
├── LeagueTable.tsx # Standings (sortable)
├── Scoreboard.tsx # Matchup pairs with week nav + playoff badging
└── PowerRankings.tsx # Server-driven sortable algorithm columns
client/src/pages/DashboardPage.tsx is the orchestrator — it fetches the shared data once and passes it down to each widget as props. Widgets are not lazy-loaded and don't self-fetch; the props-down model keeps a single owner of the dashboard's data.
File: client/src/widgets/dashboard/LeagueTable.tsx
The League Table uses the shared useSortedRows hook + a custom SortHeader (not the generic SortableTable component) so it can keep the bespoke newspaper grid styling. Columns are inline JSX, not an array.
- Add a sort column entry to the
sortColumnsarray. Theidmust be unique;defaultDirdecides whether the first click on the header sorts asc or desc.
{ id: 'streak', sortValue: r => computeStreak(r), defaultDir: 'desc' },- Adjust the grid template so there's a slot for the new column.
const LEAGUE_TABLE_GRID =
"grid-cols-[16px_1fr_42px_42px_42px_36px_36px] sm:grid-cols-[18px_1fr_52px_52px_52px_44px_44px]";- Add a header in the header row, in display order:
<SortHeader id="streak" label="Streak" currentId={sortId} dir={sortDir} onSort={handleSort} align="right" />- Render the cell in the body row, in the same display order:
<div className="text-right font-mono text-[11px] text-body">{computeStreak(r)}</div>Sort behavior is automatic once the column is in sortColumns. The leftmost # column always shows the canonical W–L rank from rankByRosterId, regardless of the active sort.
Power Rankings columns are driven by the server. Register an algorithm and the column appears automatically — no client edits required.
File: server/src/algorithms/myAlgo.ts
import { registerAlgorithm } from '../services/powerRankingsService.js'
registerAlgorithm({
id: 'my_algo', // unique snake_case key
label: 'My Algo', // column header
description: 'What it measures', // tooltip
displayMode: 'score', // 'score' (numeric) or 'rank' (#N display)
compute({ rosters, matchupsByWeek, currentWeek }) {
const scores = new Map<number, number>()
for (const roster of rosters) {
scores.set(roster.rosterId, /* your score */)
}
return scores // higher score = better rank
},
})File: server/src/algorithms/index.ts
import './myAlgo.js' // ← add this lineThe client (client/src/widgets/dashboard/PowerRankings.tsx) renders one column per server-supplied entry in data.columns, with sorting wired automatically. displayMode: 'rank' columns sort ascending by default (rank 1 first), 'score' sort descending.
interface PowerRankingInput {
rosters: Roster[] // record, pointsFor, pointsAgainst per team
matchupsByWeek: Matchup[][] // index 0 = week 1, each Matchup has rosterId + points
users: TeamUser[] // display names / avatars
currentWeek: number
}- Create the widget at
client/src/widgets/dashboard/MySection.tsx:
import { SectionHead } from "./_shared";
import type { Roster, TeamUser } from "../../types/fantasy";
export function MySection({
rosters,
users,
}: {
rosters: Roster[];
users: TeamUser[];
}) {
return (
<section>
<SectionHead kicker="My Kicker" title="My Section" rule="subtitle" />
{/* your content */}
</section>
);
}- Wire it into
DashboardPage.tsx— import it and drop it into the body where you want it to appear:
import { MySection } from "../widgets/dashboard/MySection";
// ...
<div className="h-4" />
<MySection rosters={rosters ?? []} users={users ?? []} />- Use the shared atoms from
_shared.tsxfor visual consistency:<SectionHead kicker="…" title="…" rule="…" />— section header (rule may be a string or arbitrary node like nav buttons)<Eyebrow>…</Eyebrow>— uppercase accent label<MatchupResult name avatar pts won big />— single matchup row<SortHeader … />— clickable sort header (paired withuseSortedRowsfromclient/src/components/sortable.ts)teamName(roster, users)/teamAvatar(roster, users)— display-name / avatar helpersordinal(n)— formats numbers as1st,2nd, etc.<Avatar avatar name size />— fromclient/src/components/Avatar.tsx(shared between dashboard and AppShell)
All colours come from CSS variables defined in client/src/styles/index.css. Use the Tailwind utilities:
bg-paper,bg-chrome,bg-highlighttext-ink,text-body,text-muted,text-accentborder-ink,border-line,divide-linefont-serif(Newsreader),font-mono(IBM Plex Mono)
Dark mode is automatic — variables swap when the .dark class is on the root div.
Default to a single column at <sm; use Tailwind's sm: / md: / lg: prefixes to layer the desktop layout on top. The page padding is px-3 sm:px-7. For tables that may overflow on narrow viewports, wrap the grid in overflow-x-auto and use minmax() on the flex column (see PowerRankings.tsx).
| Hook | Data |
|---|---|
useLeagueRosters(leagueId) |
All rosters (record, pointsFor, pointsAgainst, players[]) |
useLeagueUsers(leagueId) |
Team names, avatars, Sleeper user info |
useLeagueMatchups(leagueId, week) |
Matchup pairs + scores for a given week |
useWinnersBracket(leagueId) |
Playoff bracket structure (used by Scoreboard for badges) |
usePlayerStats(season, week) |
Per-player pts_ppr, rush_yd, rec_yd, etc. |
useNFLPlayers() |
Full player dictionary (name, position, team) |
useNFLState() |
Current week, season, season type |
usePowerRankings(leagueId) |
Algorithm-based power ranking rows + columns |
useMyClaimedTeam(leagueId) |
Current user's claimed team (teamName, avatar, rosterId) |
Note: the dashboard scopes
weekandseasonto the selected league's season, not the live NFL state. The live week is only used when the selected league matches the active regular season; otherwise it falls back to week 17 (regular-season finale) so finished and offseason leagues still show real matchup data. Pre-draft / drafting leagues setlastWeekto 0 so widgets like Scoreboard can lock their nav.
There are two patterns; pick based on the visual style you want.
Use this when you want full control over the row markup and grid layout (see LeagueTable.tsx and PowerRankings.tsx):
import { useSortedRows, type SortableColumn } from "../../components/sortable";
import { SortHeader } from "./_shared";
const sortColumns = useMemo<SortableColumn<Roster>[]>(() => [
{ id: "rank", sortValue: r => rankBy.get(r.rosterId) ?? Infinity, defaultDir: "asc" },
{ id: "pf", sortValue: r => r.pointsFor, defaultDir: "desc" },
], [rankBy]);
const { sortedRows, sortId, sortDir, handleSort } = useSortedRows(
rosters,
sortColumns,
"rank",
"asc",
);Header cells are then explicit JSX — <SortHeader id="pf" label="PF" currentId={sortId} dir={sortDir} onSort={handleSort} align="right" /> — and rows render however you want them to look.
Use the generic component when you want a standard <table> with no special styling (see future card-based widgets). Columns are an array, rendering is delegated:
import { SortableTable, type TableColumn } from "../../components/SortableTable";
const COLUMNS: TableColumn<MyRow>[] = [
{ id: "name", label: "Name", render: r => <span>{r.name}</span> },
{ id: "value", label: "Value", align: "right",
sortValue: r => r.value, render: r => <span>{r.value}</span> },
];
<SortableTable
columns={COLUMNS}
rows={myRows}
getKey={r => r.id}
defaultSortId="value"
defaultSortDir="desc"
/>Columns with a sortValue are clickable — first click sorts desc, second click reverses.
When a section needs multiple pages (first example: Schedule, which grew a
"Schedule Generator" alongside the season schedule view; second: League,
which grew a Forum), nest routes under the parent path with an in-page tab
strip, and mirror the same sub-pages as an expandable group in
Sidebar.tsx. See client/src/pages/ScheduleLayout.tsx /
client/src/pages/LeagueLayout.tsx for the tab strip, and Sidebar.tsx's
TOP_NAV_ITEMS for the expandable group.
These are two independent, manually-synced representations of the same
sub-page list (the layout's TABS array and the sidebar's subItems array)
— there's no shared source of data between them. Update both when adding or
renaming a sub-page.
- Wrap the section in a layout component that renders a tab strip
(
NavLinkto each sub-route, styled likeSideBetsPage.tsx's filter tabs) plus an<Outlet />:
const TABS = [
{ to: "/schedule", label: "Season Schedule", end: true },
{ to: "/schedule/generator", label: "Schedule Generator", end: false },
];
// <NavLink to={to} end={end} className={({isActive}) => ...}>{label}</NavLink>
// <Outlet />- Nest the routes in
App.tsxunder the layout, with the original page as theindexroute:
<Route path="/schedule" element={<ScheduleLayout />}>
<Route index element={<SchedulePage />} />
<Route path="generator" element={<ScheduleGeneratorPage />} />
</Route>- Add a
subItemsarray to the item's entry inSidebar.tsx'sTOP_NAV_ITEMS:
{
label: "Schedule",
to: "/schedule",
icon: Calendar,
end: true,
subItems: [
{ label: "Season Schedule", to: "/schedule", end: true },
{ label: "Schedule Generator", to: "/schedule/generator", end: false },
],
},An item with subItems renders as a plain toggle <button> (no
navigation of its own — matching the existing "Teams" disclosure
pattern), since its index sub-item already covers that route. end
on the parent entry is unused in that case but still required by the
shared item type; leave it true. The rendering, expand/collapse
state (expandedGroups), and auto-expand-on-matching-route behavior
are already generic over TOP_NAV_ITEMS — no other Sidebar.tsx
changes are needed. A group with no subItems (e.g. Draft) still
renders as a normal NavLink.
Reuse this shape rather than inventing a new one when the next section needs more than one page.
The Trophy Room has two layers: auto-generated stat trophies (computed from TeamStats) and commissioner awards (stored in the DB and granted per-team). Both render as the same TrophyCard-style grid.
All auto-trophy logic lives in client/src/pages/TeamPage.tsx.
| Thing | What it does |
|---|---|
TrophyTier |
Visual style preset — gold, silver, bronze, ribbon, wood |
Trophy["kind"] |
Which SVG glyph to render — cup, medal, ribbon, star, wood |
buildTrophies(stats) |
Pure function that maps TeamStats → Trophy[] |
All you need to do is push a new Trophy object into the array inside buildTrophies. The TrophyCard component and grid rendering are automatic.
| Tier | Glyph | When to use |
|---|---|---|
gold / cup |
Trophy cup | Champion, winner |
silver / medal |
Olympic medal | Runner-up |
bronze / medal |
Olympic medal | 3rd place |
ribbon / star |
Star badge | Stat superlatives (high score, etc.) |
ribbon / ribbon |
Ribbon rosette | Participation / honourable mentions |
wood / wood |
Plank / data grid | Shame awards, consolation, career counts |
Open buildTrophies in TeamPage.tsx and push a Trophy object:
if (stats.highScore && stats.highScore.points > 200) {
trophies.push({
id: "scorigami", // must be unique across all trophies
title: "Scorigami",
sub: `${stats.highScore.points.toFixed(2)} pts in a single week`,
detail: "200-point club",
year: stats.highScore.season,
tier: "gold",
kind: "star",
});
}Each auto-trophy type can be enabled or disabled per-huddle by the commissioner from the Trophy Room panel in the Commissioner dashboard. The active state is stored in huddle_active_trophies and fetched via useActiveTrophies(huddleId). buildTrophies output is filtered against this before rendering.
Type keys (must match BUILT_IN_TROPHY_TYPES in trophyControlService.ts):
champion,runner_up,third,high_score,missed_playoffs
Commissioners grant one-off awards to specific teams from the Commissioner dashboard → Trophy Room panel. Awards are stored in huddle_awards and displayed alongside auto-trophies in the team's Trophy Room.
huddle_awards: id, huddleId, rosterId, glyph, color (hex), title, description, grantedBy, season
GET /api/huddles/:id/awards— all awards;?rosterId=Nfor a specific teamPOST /api/huddles/:id/awards— create (commissioner only)PATCH /api/huddles/:id/awards/:awardId— update (commissioner only)DELETE /api/huddles/:id/awards/:awardId— delete (commissioner only)
useAwards(huddleId, rosterId?) // fetch awards
useCreateAward() // POST
useUpdateAward() // PATCH
useDeleteAward() // DELETEGlyphs are SVG icons rendered in the award card. There are two kinds:
Built-in glyphs — hardcoded SVGs in GlyphSvg (CommissionerPage), CommissionerGlyph (TeamPage), and SettingsGlyphSvg (LeagueSettingsPage). Current set: cup, medal, ribbon, star, bolt, trash.
Custom icon files — SVG files dropped into server/src/assets/award-icons/. These appear in the picker automatically (no restart needed). The glyph field is stored as icon:<filename-without-extension> (e.g. icon:crown).
To add a new icon:
- Drop a
.svgfile intoserver/src/assets/award-icons/ - Use
viewBox="0 0 36 40",currentColorfor stroke/fill,stroke-width="1.4", round caps/joins - It appears in the picker immediately — see the README in that directory for the full format spec
To add a new built-in glyph:
- Add
{ kind: "mykind", label: "My Label" }toAWARD_GLYPHSinCommissionerPage.tsx - Add an
if (kind === "mykind") return (<svg>…</svg>)branch inGlyphSvg,CommissionerGlyph, andSettingsGlyphSvg - Follow the same 36×40 viewBox /
currentColor/ 1.4px stroke convention
stats.seasons[] // per-season: record, PF, PA, seed, postseason result
stats.careerRecord // { wins, losses, ties }
stats.winPct // 0–1
stats.playoffAppearances
stats.championships
stats.runnerUps
stats.thirdPlace
stats.avgFinish // average seed across all seasons
stats.avgPointsFor
stats.avgPointsAgainst
stats.highScore // { points, season, week, opponentRosterId }
stats.lowScore
stats.biggestWin // { margin, season, week, opponentRosterId }
stats.worstLoss
stats.longestWinStreak
stats.longestLossStreak
stats.h2h[] // { opponentRosterId, wins, losses, ties }- Auto-trophy IDs must be unique — collisions cause React key warnings
- Cards render in push order — put prestigious awards first
yearis always string or number; use"Career"for aggregate awards- The Trophy Room section header count includes both auto-trophies and commissioner awards
The provider pattern lives in server/src/providers/. To add a new platform:
- Create
server/src/providers/myplatform/— implement theFantasyProviderinterface from../types.ts - Register it in
server/src/providers/registry.ts - In the client, new leagues from this provider will appear under their own
<optgroup>in the nav dropdown (updateAppShell.tsxto add the group)
User profile flows: users table → GET /api/user/me → AuthGuard → Redux auth slice
AuthGuardblocks render onuseCurrentUser()(client/src/hooks/useCurrentUser.ts), then callssetUser(). It re-runs whenever that query is invalidated.- After mutating the profile server-side, call
useInvalidateCurrentUser()on the client. Do not useawait user.reload()— that was the old Clerk-metadata cache-bust and no longer affects anything we read. - The
authslice holds:user(id, username, email, sleeperUsername, sleeperUserId, syncedLeagueIds),selectedLeagueId,selectedYear selectedLeagueIdandselectedYearare persisted tolocalStorage(huddle:selection) by the store subscriber inclient/src/store/index.ts, so the user's chosen league/season survives a refresh.
Clerk owns sessions only. Everything else about a user is ours, in the users table.
| Data | Owner | Notes |
|---|---|---|
| Session / JWT / sign-in UI | Clerk | clerkMiddleware() + requireAuth, getToken() client-side |
email, username |
Clerk, cached by us | Refreshed by usersService.ensureUser on a 24h TTL |
sleeperUsername, sleeperUserId, syncedLeagueIds |
Us | Formerly Clerk unsafeMetadata; migrated in 0012 |
Three rules keep a future auth migration cheap — they exist because the alternative is a rewrite:
users.idis the Clerk user id, and stays that way. All 18 user-referencing columns across 11 tables store that same string. Swapping providers later means issuing our own JWTs with the samesub, not renumbering the database. Never migrate these to fresh uuids.- Never call Clerk for display names. Use
usersService.getUserSummaries(ids)— one SQL round-trip.huddleRoutesused to make aclerkClient.users.getUserList()HTTP call inside five request handlers; that's what this replaced. - Never
DELETEausersrow. Every FK isON DELETE RESTRICTon purpose — forum posts, votes and survey answers are league history that must outlive an account. To delete a user, blank the identity fields and setis_placeholder.
AuthGuard gates the whole app, so every wait it performs needs an exit. It blocks render on two things — Clerk loading, and GET /api/user/me — and each has a failure mode that produces no error to observe, only an indefinite spinner. Both are now bounded, and the shape of each is worth knowing before you touch that file:
| Wait | Silent-hang mode | Guard |
|---|---|---|
| Clerk's script | isLoaded just stays false forever; Clerk retries, gives up, raises nothing the app can see |
CLERK_LOAD_TIMEOUT_MS, then a failure screen |
GET /api/user/me |
query gated on a different signal than the guard branches on → never runs, never errors | both read useAuth(); nothing in AuthGuard reads useUser() |
GET /api/user/me |
a 200 whose body isn't ours (auth wall, SPA catch-all rewrite) → .user is undefined, not a throw |
parseProfile narrows the body and throws |
The rule: anything AuthGuard waits on must be able to fail loudly. A new if (!x) return <Spinner/> needs a path that makes x resolve or surface an error, or you've added an unfalsifiable hang to app boot. Do not read sign-in state from useUser() here — it's derived from the cached user object rather than the session token and can disagree with useAuth().
Linking a Sleeper account: deep-link to the Integrations page. Our Sleeper form is a custom <UserProfile.Page url="integrations"> inside Clerk's <UserProfile>. useAccountModal().open() lands on Clerk's default profile page, leaving the user to find Integrations in Clerk's own nav — which collapses behind a hamburger below md, so on a phone it's a hamburger inside a modal. open("integrations") opens the modal already on that page. Any new "connect your Sleeper account" prompt should pass the tab (see CreateHuddleModal's empty state) rather than telling the user to go find Account → Integrations.
How that deep link works — the two obvious ways are both dead ends. A mounted <UserProfile> routes through the URL fragment; clicking Integrations by hand puts #/integrations in the address bar. So routing="virtual" does nothing (it applies to the modal Clerk renders itself via openUserProfile()), and neither does __experimental_startPath, which only that same Clerk-owned modal reads. Both typecheck and both silently no-op — there's no error to tell you they were ignored, so verify this behavior in the browser rather than by compiling.
What works is driving the fragment: open() writes the hash via replaceState before the modal mounts, and Clerk's hash router reads it on the way in. The URL is therefore the single source of truth for the visible page — don't reintroduce a parallel tab state, it would drift from whatever the user clicks in Clerk's own nav. close() and a plain open() both clear the hash; skipping that leaves #/integrations behind and every later "Account" click lands on the wrong page.
scripts/backfill-users.mjs (npm run db:backfill-users --prefix server) mirrors Clerk's user list into users. It's idempotent and safe to re-run on a schedule — identity fields are always refreshed, but Sleeper fields are only filled when ours are empty, so it can never roll back a user's later edits. Run it after any migration that adds a new user-referencing column, and add that column to the USER_ID_COLUMNS list at the top of the script.
schema.ts is the source of truth. The running app reads it directly — Drizzle's query builder never looks at drizzle/. Migrations exist only to move an existing database from one state to the next.
Two databases as of 2026-08. Local
.env'sDATABASE_URLpoints at a dev/test Neon database — free to fill with dummy records. Vercel's Production environment variable (set in Vercel's dashboard, not in.env— see CLAUDE.md) points at a separate Neon database (a schema-only branch of the dev one) that holds real user data..envnever reaches Vercel, so these two are only ever kept in sync by deliberately applying the same migration to both — there's no automatic replication between them (schema-only branches are independent root branches in Neon, with no parent/child "reset" relationship). Every schema change below needs to be applied against both connection strings.
- Edit
server/src/db/schema.ts. npm run db:generate --prefix server— writes a new.sqland updatesdrizzle/meta/.- Read the generated SQL. Drizzle guesses, and its guesses can be destructive (see below). This review is the whole point of the generate workflow.
- Apply it to the dev DB:
npm run db:migrate --prefix server. - Apply the same migration to the production DB:
npm run db:migrate:prod --prefix server. Do this deliberately, ideally right after step 4 while the change is fresh — a schema drifting out of sync between the two is the main risk this workflow introduces. - Commit the
.sqlandmeta/together, in the same commit. They are one unit.
Both db:migrate commands are thin wrappers around Drizzle's own migration runner (server/scripts/migrate.mjs), which tracks what's already been applied per database in a drizzle.__drizzle_migrations table — so each command only runs whatever's new since the last time you ran it against that particular database, and is safe to re-run (a no-op if nothing's pending). db:migrate uses local .env (dev DB); db:migrate:prod uses server/.env.production — a gitignored, local-only file holding the prod connection string, never committed and never touching Vercel. If that file doesn't exist on your machine, create it with a single DATABASE_URL=... line (the prod connection string, from Vercel's dashboard).
This replaces the old psql -f ... workflow — no psql install required, and no manual bookkeeping of which files have already been run against which database.
- Never hand-write a migration. A hand-written file doesn't update the snapshot, so the next
db:generatediffs against a lie. This is exactly how the meta went stale before the 2026-07-21 re-baseline. If you dislike the generated SQL, edit the generated file — the snapshot still gets written correctly. - Never
db:pushagainst prod. Push skips migrations and doesn't update the snapshot, reintroducing drift. It's fine against a scratch database; just don't mix push and generate on the same one. - Never run
0000_baseline.sqlagainst prod. It describes the full current schema for provisioning empty databases only. Pre-baseline migrations are indrizzle/archive/for history. db:migrate/db:migrate:proddecide what's "new" by comparing migration timestamps to the latest row indrizzle.__drizzle_migrations— if that table's history ever looks wrong (e.g. a migration applied out of band, bypassing the script), fix the table directly rather than letting the next run guess. Both the dev and prod databases were "baselined" (given a row per pre-existing migration, without re-running the SQL) when this tooling was introduced in 2026-08 — see git history onserver/scripts/migrate.mjsif you need to do that again for a fresh database.
| Change | Watch for |
|---|---|
| Renaming a column | Drizzle sees drop + add and generates DROP/ADD, silently discarding data. Resolve as a rename, or hand-edit to ALTER ... RENAME. |
Adding NOT NULL |
Fails on a populated table unless you give it a default. |
| Removing an enum value | Postgres can add values but not drop them; requires recreating the type. |
| Changing a column's type | Drizzle emits a bare SET DATA TYPE, which Postgres rejects whenever no implicit cast exists (e.g. integer → boolean in 0013). Hand-edit to DROP DEFAULT → SET DATA TYPE ... USING (...) → SET DEFAULT. |
| Renaming a table | Needs a TTY — drizzle-kit generate prompts "created or renamed?" per table, and answering "create" produces a DROP/CREATE that discards the data. For a bulk rename it's safer to answer "create" deliberately (the snapshot is correct either way, since it records end state) and then replace the generated SQL with ALTER TABLE ... RENAME. Constraint and index names don't follow the rename; see "Table naming". |
| Deploys | Never auto-migrate on boot — Vercel serverless would race on every cold start. Migrations are always run deliberately. |
Tables are named for what they hold. No product-name prefix.
commissioners, polls, survey_responses — not huddle_commissioners, huddle_polls. The huddle_ prefix that 19 tables used to carry said nothing: every table in this database belongs to Huddle. It cost real ergonomics — \dt and editor autocomplete on hud returned almost the whole schema — and it implied a scoping rule it didn't keep (team_claims and side_bets carried huddle_id without the prefix; the poll/survey child tables carried the prefix without a huddle_id). 0015 removed it from all 21 remaining tables.
The one structural distinction worth knowing isn't in the names: users is global; everything else is reachable from a huddle, directly via huddle_id or transitively through a parent (poll_options → polls → huddle). If you add auth tables later (sessions, accounts), they join users on the global side.
The same rule applies to TypeScript types (Award, Poll, SurveyResponse, not HuddleAward), on both sides of the wire — the client had already settled on the unprefixed names, so 0015's follow-up brought the server in line rather than the other way round. Keep the Huddle prefix only where it's doing real work: Huddle itself, HuddleClaim, HuddleDetailResponse, HuddleMemberStatus. Watch for collisions with lucide-react icons when you strip a prefix — CommissionerPage imports Award as LucideAward for exactly this reason.
Renaming a table is a three-part job, and Postgres only does the first part. ALTER TABLE ... RENAME TO leaves every constraint and index still carrying the old name, and Drizzle doesn't track those names, so they will never show up in a generated diff — they just rot until something collides. 0015 renamed 115 objects for 21 tables. Generate the statements from the live schema (pg_constraint + pg_index) rather than by hand.
Two traps that cost a follow-up migration (0016) the first time:
- The 63-byte identifier limit. Postgres silently truncates longer constraint names, so
huddle_survey_answer_options_option_id_huddle_survey_options_id_fklost its_fksuffix on creation. Renaming carried the truncation forward, leaving a name Drizzle's snapshot didn't expect. - Verify against the snapshot afterwards.
db:generatereporting "no schema changes" only provesschema.tsmatches the snapshot — not that the database does. Diff the snapshot'sforeignKeys/indexesnames againstpg_constraint/pg_indexesdirectly; that's what caught the truncation.
| What | Where |
|---|---|
| Route definitions | client/src/App.tsx |
| Shared layout (top nav + sidebar) | client/src/components/AppShell.tsx |
| Sidebar nav items | client/src/components/Sidebar.tsx |
| Auth guard + Redux hydration | client/src/components/auth/AuthGuard.tsx |
| Redux auth slice | client/src/store/slices/authSlice.ts |
| Redux store + persistence | client/src/store/index.ts |
| Sleeper data hooks | client/src/hooks/useSleeper.ts |
| Huddle hooks (claims, awards, dues, payouts, trophies) | client/src/hooks/useHuddles.ts |
| Dashboard orchestrator | client/src/pages/DashboardPage.tsx |
| Dashboard widgets | client/src/widgets/dashboard/*.tsx |
| Dashboard shared atoms | client/src/widgets/dashboard/_shared.tsx |
| Sub-page layout example (tabs + Outlet) | client/src/pages/ScheduleLayout.tsx |
| Schedule generator page (orchestrator) | client/src/pages/ScheduleGeneratorPage.tsx |
| Schedule generator widgets | client/src/widgets/scheduleGenerator/*.tsx |
| Schedule generator algorithm (pure, client-side) | client/src/utils/scheduleGenerator.ts |
Shared Avatar |
client/src/components/Avatar.tsx |
| Sortable hook + types | client/src/components/sortable.ts |
Generic SortableTable |
client/src/components/SortableTable.tsx |
| League family helpers | client/src/utils/leagueFamily.ts |
| Power rankings service | server/src/services/powerRankingsService.ts |
| Power ranking algorithms | server/src/algorithms/ |
| Provider routes | server/src/routes/providerRoutes.ts |
| Huddle routes | server/src/routes/huddleRoutes.ts |
| DB schema | server/src/db/schema.ts |
| DB migrations | server/drizzle/ |
| Awards service | server/src/services/awardsService.ts |
| Trophy control service | server/src/services/trophyControlService.ts |
| Custom icon files | server/src/assets/award-icons/ |