## Summary
- Removes the last in-process \`setInterval\` (\`server/snapshots.ts\` 24h loop) and replaces it with an external HTTP cron job via Forgejo Actions
- Adds automated standings sync + conditional simulation: syncs every 2h, only simulates when standings actually changed (detected by comparing \`gamesPlayed\`/\`leagueRank\` before upsert)
- Adds \`GET /healthz\` for Docker healthcheck (Phase B prerequisite)
## What's new
| Endpoint | Triggered by | What it does |
|---|---|---|
| \`POST /admin/jobs/run-daily-snapshots\` | Forgejo schedule \`5 0 * * *\` | Creates daily fantasy standings snapshots for all active/draft seasons |
| \`POST /admin/jobs/sync-and-simulate\` | Forgejo schedule \`0 */2 * * *\` | Syncs standings from external APIs; runs simulation only if standings changed |
| \`GET /healthz\` | Docker / Traefik | Returns 200 \`{ok:true}\` when DB reachable, 503 otherwise |
Both cron endpoints are protected by \`X-Cron-Secret\` header (set \`CRON_SECRET\` in Forgejo repo secrets + production env).
## Schema changes (migration 0118)
Two new nullable columns on \`sports_seasons\`:
- \`standings_last_changed_at\` — written by \`syncStandings()\` when data actually changes
- \`last_simulated_at\` — written by the cron job after a successful simulation run
## Deployment notes
1. Add \`CRON_SECRET\` to Forgejo repo secrets (generate with \`openssl rand -hex 32\`)
2. Add same value to production environment
3. Migration runs automatically via the \`migrate\` container on deploy
## Test plan
- [ ] \`curl -X POST https://brackt.com/admin/jobs/run-daily-snapshots -H "X-Cron-Secret: ..."\` → 200 \`{total, succeeded, errors}\`
- [ ] \`curl -X POST https://brackt.com/admin/jobs/sync-and-simulate -H "X-Cron-Secret: ..."\` → 200 with \`synced\`/\`unchanged\`/\`simulated\` breakdown
- [ ] \`curl https://brackt.com/healthz\` → 200 \`{ok:true}\`
- [ ] Verify Forgejo workflow runs appear in Actions tab after merge
- [ ] Kill web process mid-day; confirm external cron still fires (no in-process dependency)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #79
- Pause snapshot used Math.floor while the client display used Math.ceil,
causing the visible clock to round down by 1s on pause click
- draft-state-sync also used Math.floor for reconnecting clients
- Extract msToSeconds() to app/lib/draft-timer.ts as the single source of
truth for all ms→seconds conversions across server and client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SportIcon renders <img> elements whose onError handler calls setFailed(true),
scheduling state updates asynchronously outside React act() in jsdom. With
multiple icons on the Sports page this caused the synchronous render test to
hit the 5000ms Vitest timeout intermittently.
Mock SportIcon in the test using resolveSportIconUrl() so the mock stays in
sync with the real URL resolution logic if it ever changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Column-sum and structural assertions are mathematically exact regardless of
iteration count; 20 iterations runs the suite in ~270ms vs the 5s CI limit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Filter the QP table to show only participants with QP > 0 or drafted by any team in the league (undrafted 0-QP participants hidden)
- Compute global ranks with tie handling across the full field before filtering so displayed rank numbers and the top-8 Points Line remain correct after rows are removed
- Fix flaky CI timeouts: world-cup simulator test (100 → 50 iterations), SportsSection userEvent test (explicit 15s timeout)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pauseDraftOnError now calls onDraftPaused() before emitting so the
error-path pause carries the same snapshotted timer data as the
manual-pause route (fixes inconsistency between the two paths)
- Parallelize DB updates in onDraftPaused with Promise.all instead of
sequential awaits
- Extract clientExpiresAt() helper to draft-timer.ts and use it in all
three places that re-anchor timeRemaining to the client clock, replacing
duplicated Date.now() + timeRemaining * 1000 expressions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- The countdown display used `data.expiresAt` (server-side Unix timestamp) subtracted from `client Date.now()`. When the server clock runs ~2-3s ahead of the client (observed in production), the display was inflated by that delta.
- Fixes three linked symptoms: timer starting at 2:02 instead of 2:00, chess-clock increment crediting 1:54 instead of 1:55, and the commissioner's adjust-time-bank dialog disagreeing with the visible countdown.
- Fix: replace all server `expiresAt` timestamps with a client-local expiry from the server-provided `timeRemaining` integer (`Date.now() + timeRemaining * 1000`). The skew then cancels algebraically when the server computes credit.
## Sites changed
- `handleTimerPickStarted` — fires after every pick (the main fix)
- `handleDraftStateSync` — reconnect path
- `useState` initializer in the draft room — initial page load seed from loader data (uses `loaderTimerNow` server timestamp to compute remaining server-side, then re-anchors to client clock)
## Test plan
- [ ] Start a chess-clock draft (2:00 bank, 15s increment) — timer should show exactly **2:00** when a team goes on the clock
- [ ] Pick at 1:40 — new bank should show **1:55** (100 + 15s)
- [ ] Commissioner adjust-time-bank dialog — displayed remaining should match the countdown
- [ ] Force autopick at any displayed time T — new bank should be T + increment
- [ ] Reconnect mid-pick — timer should resume at the correct remaining time
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #74
Migration 0116 added these columns to draft_timers but was generated
with an out-of-order timestamp and was never applied to the DB. Migration
0117 re-adds them with IF NOT EXISTS guards so the apply is safe
regardless of the DB's current state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started`
- **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display
- **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick
- **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire
- **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero
- **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event
- **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload
- **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt`
- **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure
- **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early
- **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction
- **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join
## Test plan
- [ ] Manual pick: all clients see bank increment immediately after pick
- [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s
- [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log
- [ ] Force-manual-pick: all clients see bank increment
- [ ] Pause while clock running: countdown freezes on all clients
- [ ] Resume: clock continues from frozen value
- [ ] adjust-time-bank on on-clock team: countdown shifts immediately
- [ ] adjust-time-bank to zero: returns 400 error
- [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team
- [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately
- [ ] Draft complete: "Room closes in X" counts down
- [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen
- [ ] `npm run test:run` — all 158 files / 2351 tests pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #72
## Summary
- **MLB simulator seeding now responds to projected wins**: replaced hardcoded FanGraphs `p_div`/`p_wc` probability draws with Binomial regular-season simulation. Admin-entered projected wins (via Elo) now drive both playoff _qualification_ odds and in-bracket game win probability, not just the latter.
- **Reads current standings mid-season**: `getRegularSeasonStandings` is called so synced `wins`/`gamesPlayed` feed into each simulation iteration's projected final standings.
- **27× seeding performance improvement**: `sampleBinomial` now uses a Box-Muller normal approximation for `n ≥ 30`, dropping the seeding phase from ~243M to ~3M `Math.random()` calls per 50K-sim run (pre-season, 162 remaining games × 30 teams).
- **Missing-standings warning**: when standings exist for some teams but not all recognized ones (partial sync), a `logger.warn` now surfaces which teams are falling back to 0 wins / 162 remaining instead of silently distorting seeding.
- **Cleanup**: `eloToRDif` calls `rawWinRateFromElo` instead of inlining the same formula.
- **Simulate errors surface inline**: moved simulation action from dedicated route to parent page `intent="simulate"` so errors display inline rather than crashing to an error page.
## Test plan
- [ ] `npm run test:run -- mlb-simulator` — 49 tests pass
- [ ] `npm run typecheck` — clean
- [ ] Trigger a simulation from the admin panel for an MLB season with projected wins entered; confirm teams with higher projected wins show meaningfully higher EVs
- [ ] Confirm that running simulate with no projected wins entered (pre-season defaults) still produces a valid bracket
- [ ] Check logs after simulating a season where some teams lack standings rows — confirm warning appears
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #65
## Problem
Running a simulation on MLB (and any sport where the readiness check fails) showed a confusing React Router 400 error instead of the real failure reason. The cause: the simulate route had only an \`action\` export. When the action **threw** instead of redirecting, React Router tried to render the route via GET to display the error — hit the missing loader — and replaced the real error with its own internal 400.
A secondary issue: the interim fix used \`?simulationError=\` query params, which had three problems identified in code review:
- Stale errors re-appeared when pressing browser Back after fixing the issue
- The param reflected arbitrary text into the UI (social engineering surface)
- The styling didn't match the established \`bg-destructive/15\` error pattern
## Changes
**\`admin.sports-seasons.\$id.simulate.tsx\`** — strip the action; keep a loader-only redirect stub so bookmarked URLs degrade gracefully to the season page.
**\`admin.sports-seasons.\$id.tsx\`** — add \`intent="simulate"\` to the action dispatcher (matching \`delete\`, \`rescore\`, \`sync-standings\`, \`finalize-standings\`). On success: redirect to expected-values (unchanged). On failure: return \`{ simulateError: message }\` displayed inline with the correct \`bg-destructive/15\` styling.
## Test plan
- [ ] Click Run Simulation on a season with a missing readiness input → error message appears inline below the description text, styled consistently with other errors on the page
- [ ] Click Run Simulation on a fully-configured season → redirects to expected-values as before
- [ ] Navigate directly to \`/admin/sports-seasons/<id>/simulate\` in browser → redirects to season page (no 400)
- [ ] Run simulation, see error, fix the issue, run again successfully → no stale error message persists
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #64
The build:server script (esbuild) expects server.ts at the working
directory root, but the Dockerfile only copied the server/ directory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Merge typecheck + lint into a single `check` job to eliminate one full
job lifecycle and one npm ci invocation (~2-4 min savings)
- Remove `apt-get install docker.io` step — Docker CLI and buildx v0.23.0
are now pre-installed in the custom brackt-runner:latest image
- Reorder Dockerfile build-env COPY layers so node_modules and config
files are copied before source, improving registry cache hit granularity
- Expand .dockerignore to exclude .forgejo, .git, docs, plans, cypress,
logs, .env files, and tsbuildinfo files from the build context
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS.
- **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters.
- **Bug fixes** found during review and applied across all affected adapters:
- `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard
- Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA
- `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0)
- statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison)
- WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally
- MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`)
## Test plan
- [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`)
- [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406
- [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #62
## Summary
- **Bug fix**: EPL Elo/odds fallback was gated on \`dbEloMap.size === 0\` — if any team had a direct \`sourceElo\`, teams with only \`sourceOdds\` were never populated and the simulator threw "Missing Elo" instead of using their odds data
- **Iterations**: WorldCupSimulator default 50k→10k; manifest \`defaultConfig\` for \`world_cup\` and \`epl_standings\` now explicitly override \`BASE_CONFIG\` with 10k so the production fallback matches; test iterations reduced (WorldCup 500→100, EPL 10k→500) to fix timeout failures on slower CI
- **Cleanup**: Extracted \`runKnockoutRound\` out of the 10k-iteration simulation loop; replaced two hot-loop \`toSorted()\` calls on 2-element arrays with a conditional string comparison; removed local \`normalizeProbabilities\` in favour of the shared \`normalizeSimulationResultColumns\`
## Test plan
- [ ] \`npm run test:run\` — all simulator unit tests pass without timeout
- [ ] Typecheck passes (\`npm run typecheck\`)
- [ ] EPL simulate with mixed Elo+odds data no longer throws
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #61
## Summary
- Removes the "probabilities are all non-negative" test from \`world-cup-simulator.test.ts\`, which was timing out intermittently in CI (5s budget, 500 Monte Carlo iterations × 48 teams)
- The test is redundant: the "column sums ≈1.0" test immediately above it already catches negative probabilities (a negative value in one column forces a sibling above 1.0, which \`toBeCloseTo(1.0, 2)\` would fail)
- Non-negative assertions for meaningful edge cases are also covered by the "SF losers land in 3rd or 4th" test
## Test plan
- [ ] \`npm run test:run\` passes with no failures
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #60
## Summary
- **Dockerfile layer caching**: `development-dependencies-env` now copies only `package.json`/`package-lock.json` before `npm ci` (was `COPY . /app`), so the npm install layer is cached on every code-only commit instead of rebuilt from scratch
- **Skip QEMU**: `platforms: linux/amd64` added to `build-push-action`, cutting `setup-buildx-action` from ~5 min to ~30 sec
- **npm cache in CI jobs**: Manual `actions/cache@v3` blocks (copy-pasted 3×) replaced with `actions/setup-node@v4` + `cache: 'npm'`, which handles path/key/restore automatically and pins Node 20 explicitly
- **Harden `.npmrc`**: Switched from `COPY .npmrc` to `--mount=type=secret,id=npmrc` in both `npm ci` stages — the file is available during install but never written into a Docker layer, so it cannot leak through the registry build cache regardless of future contents
## Expected timing
| Step | Before | After (code change) |
|---|---|---|
| Setup buildx | ~5 min | ~30 sec |
| Build + push | ~5 min | ~1-2 min |
| Deploy (docker pull) | ~5 min | ~1-2 min |
| **Total** | **~15 min** | **~3-5 min** |
## Test plan
- [ ] Push a code-only commit to main — confirm `setup-buildx-action` logs ~30s (no QEMU), Docker build shows `CACHED` for npm install layers, build+push completes in ~1-2 min
- [ ] Check deploy job — `docker compose pull` should show most layers as `Already exists`
- [ ] Push a commit that changes `package.json` — confirm npm layer correctly re-runs (not cached)
- [ ] Confirm deployed app is functional
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #59
## Summary
- **Per-league queue**: replaced the single global notification queue with a \`Map<leagueId, queue>\` so concurrent drafts in different leagues drain independently and don't block each other
- **Deadlock fix**: wrapped the drainer loop in \`try/finally\` so \`drainingLeagues\` is always cleaned up even if a task throws unexpectedly — previously a crash would leave the flag set permanently, silently dropping all future notifications for that league
- **Wait cap fix**: moved \`Math.min\` to wrap \`(retryAfter * (attempt+1))\` so the 10s ceiling applies to the final product, not just the base value — settings actions that directly await webhook calls were previously blockable for up to 30s on a rate-limited third attempt
## Root cause of the original problem
During an autodraft chain, \`notifyPickMadeOnDiscord\` was called fire-and-forget for each pick in the chain, causing all webhook requests to fly concurrently. Discord's per-webhook rate limit (~5 req/2s) rejected most of them; the single retry logic fired for all simultaneously, hit the limit again, and the \`.catch()\` swallowed the errors silently.
## Test plan
- [ ] Enable autodraft for 5+ consecutive teams; trigger the chain; confirm all picks announce in Discord in order
- [ ] Confirm a slow/rate-limited webhook in League A does not delay League B's announcements
- [ ] \`npm run test:run\` — 158 test files, 2336 tests, all pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #57
## Summary
- The 15-minute session keep-alive interval in `useDraftAuthRecovery` had no `try-catch`. When `authClient.getSession()` is called while the network is down (e.g. a laptop waking from sleep), `better-fetch` throws a `TypeError: Failed to fetch` — producing an unhandled promise rejection that was reported in Sentry.
- Fixed by wrapping the interval callback in a `try-catch`. Network-level `TypeError`s are silently swallowed because real session expiry (HTTP 401) is returned in-band as `{ data: null }` by `better-fetch` and already caught by the `!session` check.
- Improved the comment to explain the mechanism rather than citing socket offline detection (which doesn't validate auth).
- Added 3 tests covering: network throw (no authDegraded), null session response (authDegraded set), valid session (no change).
## Root cause
User left the draft page open overnight. The 15-minute ping fired repeatedly. At some point the computer woke from sleep with the network not yet ready — `getSession()` threw, `setInterval` dropped the rejected promise, Sentry caught it as an unhandled rejection. Confirmed via Sentry breadcrumbs: last activity May 26 8:32 PM, error fired May 27 12:23 PM (~28 hours later).
## Test plan
- [x] 3 new tests pass
- [x] typecheck clean
- [ ] After deploy: monitor Sentry for recurrence
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #56
## Summary
- **Group stage match list**: Removed MD 1/2/3 headings; matches now listed chronologically under date separators (e.g. "Jun 14"). Kick-off time appears below the team names instead of between them.
- **Sort order**: \`findMatchesByGroupIds\` / \`findMatchesByEventId\` now order by \`scheduledAt ASC NULLS LAST\` so unscheduled matches always trail scheduled ones.
- **Groups/Bracket toggle**: When both group stage and knockout bracket exist, a toggle appears (mirroring the NBA/AFL standings toggle). Sports with both regular-season standings and a group stage get all views available.
- **Upcoming events**: \`getUpcomingEventsForDraftedParticipants\` now also queries \`groupStageMatches\`, so World Cup group fixtures appear on the home page calendar sorted by kick-off time with a label like "Group A — France vs Germany".
- **\`isAllCompete\` fix**: \`UpcomingEventsCard\` and \`UpcomingCalendarPanel\` now treat \`group_stage_match\` as a bracket-style event, showing team badges instead of "N of your picks".
Fixes#53
## Test plan
- [ ] Navigate to a World Cup sports season page — groups should be listed chronologically under date headers with no MD labels; kick-off time should appear below each match row
- [ ] Once the knockout bracket has matches, confirm the Groups/Bracket toggle appears and both views render correctly
- [ ] On the home page, a user with drafted World Cup participants should see upcoming group fixtures in the calendar panel with individual team badges (not a participant count)
- [ ] NBA/AFL/NHL pages unaffected — their standings/playoffs/finished toggle still works normally
- [ ] \`npm run typecheck\` and \`npm run test:run\` pass
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #55
## Summary
- Swap side-effect order so `updateProbabilitiesAfterResult` runs before `recalculateAffectedLeagues` in `processMatchResult`, `processPlayoffEvent`, and the `set-round-winners` route — projected points now reflect the new result immediately instead of waiting on the next simulation.
- Eliminate the N+1 EV lookup in `calculateTeamProjectedScore` by pre-fetching every EV the season needs in a single `inArray` query inside `recalculateStandings` and passing the map down.
- Add `skipProbabilities` option to `processPlayoffEvent`, used from `autoCompleteRoundIfDone` (callers have already refreshed EVs), avoiding a redundant pass on round-completing saves.
Fixes#31
## Test plan
- [x] `npm run typecheck`
- [x] `npx vitest run app/models/__tests__/team-projected-score.test.ts app/models/__tests__/process-match-result.test.ts app/services/__tests__/probability-updater.test.ts` — 57/57 passing
- [ ] Manual: save a round of bracket winners on a sports season with at least one fantasy league and confirm (a) the request returns faster and (b) the team standings show projected points updated to reflect the result without re-running the simulator
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #54
- Point Sitemap directive at /sitemap.xml (not .gz) so crawlers receive
plain XML without needing Content-Encoding headers
- Add Disallow: /leagues/ to block all auth-gated league routes
- Gitignore public/sitemap.xml* so they are always generated fresh at
build time rather than serving a stale committed snapshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a static robots.txt blocking admin/API/auth routes, a sitemap
generation script (scripts/generate-sitemap.mjs) that writes both
sitemap.xml and sitemap.xml.gz to public/, and hooks it into the
build so the sitemap is always fresh on deploy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move notifyPickMadeOnDiscord() before checkAndTriggerNextAutodraft() in
both executeAutoPick() and the manual pick action. Previously the
triggering pick's announcement fired after all chained autodraft picks
had already announced, causing Discord to show e.g. pick #106 before
#105 before #104. Now each pick announces itself before the next pick
in the chain is triggered.
- Add a single retry with Retry-After delay in sendDiscordWebhook() on
HTTP 429. A burst of consecutive autodraft picks could hit Discord's
per-webhook rate limit and silently drop announcements. Use || 1
(not ?? 1) to handle non-numeric/NaN headers, and cap at 10 s so
that awaited callers such as the draft-order settings actions are
never hung for a full global rate-limit window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When next_queue autodraft fires and turns itself off, two bugs prevented
the UI from behaving correctly:
1. After reconnect, useDraftAuthRecovery synced autodraftStatus (draft
grid) but not userAutodraft (settings panel), leaving the settings
badge stale until the next page load.
2. Toast notifications for autodraft auto-disable were unreliable: the
condition checked prev.mode rather than why the server disabled it,
causing the wrong toast for manual disables and queue-empty events.
Fix the reconnect sync by calling setUserAutodraft in the revalidation
effect alongside setAutodraftStatus. Fix the toast logic by adding a
reason field ("pick_complete" | "queue_empty") to the server's
autodraft-updated emit, so the client can show the right message without
guessing from local state. Also removes the now-unused userAutodraftRef.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add findTeamByNameInSeason (exact case-insensitive match via lower()) to the team model
- Validate uniqueness in the user-facing team settings rename action
- Validate uniqueness in admin assign-owner and remove-owner paths that auto-generate names
- Reject whitespace-only team names that would trim to an empty string
- Add DB-level unique index on (season_id, lower(name)) to close the TOCTOU race
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use x-forwarded-proto header (allowlisted to http/https only) when
building the invite link origin so it shows https behind a TLS proxy
- Show the "View Draft Board" bare card in the right column while a
draft is running, matching the card already shown for active/completed
seasons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a manual pick triggered an immediate autodraft chain, both Discord
announcements re-read currentPickNumber from the DB, which had already
advanced past the chained pick. Both messages showed the same "On the
clock" person, pinging them twice.
Fix: pass nextPickNumber (the pick immediately following each specific
pick, before any chain) as an explicit param to notifyPickMadeOnDiscord
instead of re-reading from the DB. Also removes a now-unnecessary DB
round-trip on every pick announcement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- **Discord snake draft fix**: pick notifications now show the sequential pick-in-round (e.g. "Round 2, Pick 9") rather than the snake-adjusted slot position ("Round 2, Pick 5"). Also removes the now-unused \`pickInRound\` param from \`notifyPickMadeOnDiscord\` and adds a regression test for the 13-team case.
- **League card**: league name in draft-in-progress cards is now a link to the league homepage (the Enter Draft button still goes to the draft room).
- **Overnight pause label**: shortened to "🌙 Pause" — the "Resumes 4:00 AM" line below already provides context, so "Overnight" was just causing wrapping.
- **Queue & Picks backgrounds**: items use \`bg-card\` instead of \`bg-muted\` for better visual separation from the panel background.
## Test plan
- [x] Discord: in a snake draft, verify pick #22 of 13 shows "Round 2, Pick 9" in Discord
- [x] League card: dashboard with a draft-in-progress league — click name → league homepage, click Enter Draft → draft room
- [x] Overnight pause: pause a draft overnight — cell shows "🌙 Pause" on one line, "Resumes X:XX" below
- [x] Queue/Picks: open draft room and confirm queue items and recent picks visually pop from the sidebar background
- [x] Unit tests: `npm run test:run` passes (13 discord tests)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #2
node:20-bookworm doesn't include the Docker CLI; install docker.io via
apt before the buildx and registry steps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Node 20 is already in the node:20-bookworm runner image; setup-node
was hanging ~5min trying to reach an unconfigured npm cache service.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Promote unicorn/no-array-sort to error in oxlint.json and replace
[...teams].sort() with teams.toSorted() in discord.ts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove host port binding (conflicts with local postgres on runner) and
use service name hostname instead of localhost for DATABASE_URL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move workflows from .github/ to .forgejo/ so they only run on Forgejo
- Replace styfle/cancel-workflow-action with native concurrency block
- Use full GitHub URLs for all third-party actions to bypass mirror
- Switch Docker layer cache from type=gha to type=registry
- Drop label-subissues workflow (relies on GitHub-specific GraphQL API)
- Add timeout-minutes to all jobs; cancel-in-progress only for non-main branches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>