Skip to content

Latest commit

 

History

History
436 lines (327 loc) · 17.3 KB

File metadata and controls

436 lines (327 loc) · 17.3 KB

Huddle Dev Playbook

Quick reference for common development tasks. Add new sections as patterns emerge.


Stack

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)

Git workflow

  • 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 main unless explicitly granted permission for a trivial chore

Dashboard layout

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.


Adding a column to the League Table (Standings)

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.

  1. Add a sort column entry to the sortColumns array. The id must be unique; defaultDir decides whether the first click on the header sorts asc or desc.
{ id: 'streak', sortValue: r => computeStreak(r), defaultDir: 'desc' },
  1. 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]";
  1. Add a header in the header row, in display order:
<SortHeader id="streak" label="Streak" currentId={sortId} dir={sortDir} onSort={handleSort} align="right" />
  1. 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.


Adding a column to Power Rankings

Power Rankings columns are driven by the server. Register an algorithm and the column appears automatically — no client edits required.

1. Create the algorithm

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
  },
})

2. Register it

File: server/src/algorithms/index.ts

import './myAlgo.js'  // ← add this line

The 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.

Algorithm input

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
}

Adding a new section to the Dashboard

  1. 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>
  );
}
  1. 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 ?? []} />
  1. Use the shared atoms from _shared.tsx for 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 with useSortedRows from client/src/components/sortable.ts)
    • teamName(roster, users) / teamAvatar(roster, users) — display-name / avatar helpers
    • ordinal(n) — formats numbers as 1st, 2nd, etc.
    • <Avatar avatar name size /> — from client/src/components/Avatar.tsx (shared between dashboard and AppShell)

Design tokens

All colours come from CSS variables defined in client/src/styles/index.css. Use the Tailwind utilities:

  • bg-paper, bg-chrome, bg-highlight
  • text-ink, text-body, text-muted, text-accent
  • border-ink, border-line, divide-line
  • font-serif (Newsreader), font-mono (IBM Plex Mono)

Dark mode is automatic — variables swap when the .dark class is on the root div.

Mobile

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).

Data available in DashboardPage

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 week and season to 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 set lastWeek to 0 so widgets like Scoreboard can lock their nav.


Sortable tables

There are two patterns; pick based on the visual style you want.

useSortedRows + SortHeader (newspaper-style, dashboard)

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.

<SortableTable> (generic, card-style widgets)

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.


Adding a custom award to the Trophy Room

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.


Auto-generated stat trophies

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 TeamStatsTrophy[]

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 reference

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

Adding a stat trophy

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",
  });
}

Commissioner trophy controls

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

Commissioner custom awards

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.

Schema

huddle_awards: id, huddleId, rosterId, glyph, color (hex), title, description, grantedBy, season

API

  • GET /api/huddles/:id/awards — all awards; ?rosterId=N for a specific team
  • POST /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)

Hooks

useAwards(huddleId, rosterId?)   // fetch awards
useCreateAward()                 // POST
useUpdateAward()                 // PATCH
useDeleteAward()                 // DELETE

Glyph system

Glyphs 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:

  1. Drop a .svg file into server/src/assets/award-icons/
  2. Use viewBox="0 0 36 40", currentColor for stroke/fill, stroke-width="1.4", round caps/joins
  3. It appears in the picker immediately — see the README in that directory for the full format spec

To add a new built-in glyph:

  1. Add { kind: "mykind", label: "My Label" } to AWARD_GLYPHS in CommissionerPage.tsx
  2. Add an if (kind === "mykind") return (<svg>…</svg>) branch in GlyphSvg, CommissionerGlyph, and SettingsGlyphSvg
  3. Follow the same 36×40 viewBox / currentColor / 1.4px stroke convention

Data available in TeamStats for buildTrophies

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 }

Notes

  • Auto-trophy IDs must be unique — collisions cause React key warnings
  • Cards render in push order — put prestigious awards first
  • year is always string or number; use "Career" for aggregate awards
  • The Trophy Room section header count includes both auto-trophies and commissioner awards

Adding a new provider (fantasy platform)

The provider pattern lives in server/src/providers/. To add a new platform:

  1. Create server/src/providers/myplatform/ — implement the FantasyProvider interface from ../types.ts
  2. Register it in server/src/providers/registry.ts
  3. In the client, new leagues from this provider will appear under their own <optgroup> in the nav dropdown (update AppShell.tsx to add the group)

Auth & user state

User metadata flows: Clerk unsafeMetadataAuthGuard → Redux auth slice

  • AuthGuard calls setUser() once on load and again whenever Clerk's user object updates (e.g. after user.reload())
  • After mutating metadata on the server, always call await user.reload() on the client to keep Clerk's cache fresh
  • The auth slice holds: user (id, sleeperUsername, sleeperUserId, syncedLeagueIds), selectedLeagueId, selectedYear
  • selectedLeagueId and selectedYear are persisted to localStorage (huddle:selection) by the store subscriber in client/src/store/index.ts, so the user's chosen league/season survives a refresh.

Key file locations

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/