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.
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 metadata flows: Clerk unsafeMetadata → AuthGuard → Redux auth slice
AuthGuardcallssetUser()once on load and again whenever Clerk'suserobject updates (e.g. afteruser.reload())- After mutating metadata on the server, always call
await user.reload()on the client to keep Clerk's cache fresh - The
authslice holds:user(id, 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.
| 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 |
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/ |