尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證AHSAA Ratings
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗AHSAA high school football power ratings for 2026. Next.js on Vercel, Supabase for storage.
PROJECT.md documents the rating engine itself and the rules that are not
obvious from reading the code. Read it before changing anything in
src/lib/engine.ts.
Run supabase/schema.sql in your Supabase project's SQL editor. It creates the
tables, the row-level security policies (public reads, service-role writes) and
the updated_at triggers.
Then run everything in supabase/migrations/ in order, and
supabase/bracket_seed.sql if you want the brackets carried over from the old
bracketology site. Each one is
schema-qualified, idempotent and safe to re-run, and each reports what it did
rather than succeeding silently. They are not folded into schema.sql, so a
database that has only had schema.sql run against it is missing forfeits
(004) and postseason bans (005).
Copy .env.example to .env.local and fill in the Supabase URL, anon key and
service role key from Project Settings → API.
Generate the admin credentials:
npm run hash-password -- "a long password you'll remember"
Paste the two lines it prints into .env.local. The password itself is never
stored — only a PBKDF2 hash.
No terminal? Use the SQL editor. Open your Supabase project → SQL Editor,
paste the contents of supabase/seed.sql, and run it. That is the whole
dataset — 393 teams, 392 preseason ratings, 90 name aliases, the formula
config, and the 139-game Week 0 schedule.
Ratings appear as soon as you open the site. Hit Recompute & publish in the admin once to cache them.
With a terminal, this does the same thing and also publishes the snapshot:
npm install
npm run setup -- --dry-run # report everything, write nothing
npm run setup # write it
Either route is idempotent and safe to re-run mid-season. Teams upsert by name, games upsert on their natural key, and a game that already has scores is never touched — nor is a formula config you have already tuned. Both behaviors are verified against a real Postgres instance, not just asserted.
Regenerate the SQL after changing anything in data/:
npm run seed:sql
npm run dev
Public site at /, admin at /admin.
Import the repo. vercel.com → Add New → Project → pick this repo. Framework detection and build settings need no changes.
Add four environment variables (Settings → Environment Variables), for Production, Preview and Development:
| Variable | Where to find it |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | Supabase → Project Settings → API → Project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY | same page, anon public key |
SUPABASE_SERVICE_ROLE_KEY | same page, service_role key — server-side only, never expose it |
ADMIN_PASSWORD_HASH | see step 3 below |
ADMIN_SESSION_SECRET | see step 3 below |
Generate the admin credentials. Deploy once with just the Supabase
variables, then visit /admin/setup on the deployed site. Enter a
password and it prints both admin variables to paste into Vercel. The
hashing happens in the page — the password itself is never transmitted.
With a terminal, npm run hash-password -- "your password" does the same.
Redeploy so the new variables take effect, then sign in at /admin.
/admin/setup and /admin/login are the only unauthenticated admin routes.
Setup reads no data and grants no access; it is a calculator.
Nothing you change reaches the public site until you publish, and ratings never move on their own. A game only counts once both scores are present, so the full season schedule can be loaded in advance without affecting anything.
| Page | What it does |
|---|---|
| Dashboard | Counts, current formula, publish button with a top-10 preview |
| Games | Add, edit and delete games; filter by week, search, show unplayed only. The schedule scan runs on open: swapped sides, a school with two games in one week, and schools missing a week everyone else played |
| Import | The AHSAA weekly sheet (pasted or as CSV); your own score CSV; the older AHSAA PDF |
| Bracketology | The bracket, region write-ups, projections and the explainer. Seeds and records are computed; pin a region to override it |
| Formula | Sliders for every tunable, with a live top-25 preview showing rank movement before you save |
| Teams | Edit names, classification, region and preseason rating; bulk-import priors |
| Bracketology → Bracket | Click any match-up on the bracket to enter its result, date, time, location and home side. A result writes straight to the games table, so the winner advances at once and the rating counts it at the next publish |
| Bracketology → Embed | Copy-paste iframe snippets, one per classification, for putting a bracket on another site |
| Site | Which sections the site shows — switching one off takes it out of the nav and makes its page 404, so a saved link cannot reach it. Plus the whole front page: headline, announcement banner, and the groups of links, with their wording, order, icons and destinations |
Renaming a team also rewrites its games, since games reference teams by name.
Removing a team is the other side of that, and it cannot be done by halves: the
engine prices any name absent from teams off the field mean, which is how an
out-of-state opponent works. A team whose row is deleted while its games remain
does not disappear — its fixtures become out-of-state ones, and its opponents
keep the win-loss while silently losing the region result. Admin → Teams →
Remove counts the games first and refuses until you confirm them too.
npm run test:engine # 24 invariant checks on the rating engine
npm run validate # reproduce the 2025 ratings from the 2025 export
npm run smoke # roster → PDF → engine, end to end, no database
npm run parse-schedule # parse report for a schedule PDF
npm run test:sheet # the AHSAA weekly Google Sheet, against a real week
npm run test:coverage # the missing-week scan, including the bye/noise rules
npm run test:forfeits # that a vacated win changes the record and not the rating
npm run test:playoffs # the odds: bracket, probability invariants, clinch/elimination
npm run test:render # what a bracket slot renders: a link for readers, not in the editor
npm run test:home # the site document: where a link may point, and what a hidden section closes
npm run test:tiebreak # the AHSAA tie-breaking procedure, (a) through (q)
npm run test:ineligible # postseason bans: what they void, and what they must not
npm run test:bracket # bracket resolution, projections, and the explainer sanitiser
npm run import:bracket # brings the old bracketology site's data across
npm run bracket:sql # the same, emitted as SQL to paste — no terminal needed
npm run calibrate:odds # refits the win-probability curve against the 2025 season
npm run typecheck
supabase/diagnose_region.sql is a pasteable query — no terminal needed. Open
the Supabase SQL editor, paste it, run it. For one classification and region it
prints the roster as stored, the region record computed the way the site
computes it, every completed game marked counted or not with the reason,
fixtures stored twice, and whether the published board predates the scores.
Change the class and region at the top to look elsewhere.
The ordering itself is covered by test:tiebreak and test:bracket — region
percentage, then region wins, then the association's factors (a)–(q), with the
Index rating standing in only for the coin flip at the very end. When a region
looks misordered, the records being fed in are what to look at.
npm run preview -- --played # builds scripts/out/preview.json from the CSVs
npm run preview:serve # serves the real UI against it
Useful for design work — renders all 393 teams and the Week 0 schedule with no
Supabase connection. --played invents deterministic results so records,
efficiency columns and result labels are populated.
src/
├── lib/
│ ├── engine.ts # THE RATING ENGINE — Massey solve + adjustments, and RPI
│ ├── types.ts # domain types, classification order, default config
│ ├── names.ts # AHSAA spelling → roster name matching
│ ├── schedule-rows.ts # shared row → game logic for both AHSAA readers
│ ├── schedule-sheet.ts# AHSAA weekly Google Sheet (CSV/TSV) parser
│ ├── schedule-pdf.ts # AHSAA schedule PDF parser (superseded, still used)
│ ├── score-csv.ts # weekly score CSV parsing and validation
│ ├── result.ts # who won on the field vs who won officially (forfeits)
│ ├── eligibility.ts # the one region-game test, and postseason bans
│ ├── bracket.ts # seeding a region, and resolving a stored bracket
│ ├── bracket-types.ts # the hand-authored bracket layer's shape
│ ├── sanitize.ts # allowlist sanitiser for the one piece of stored HTML
│ ├── playoffs.ts # bracket structure and the Monte Carlo playoff odds
│ ├── duplicates.ts # fixtures stored twice; a school with two games in a week
│ ├── coverage.ts # schools missing a week everyone else played
│ ├── tiebreak.ts # region ordering, shared by standings and the odds
│ ├── team-view.ts # schedule, projections, result classification
│ ├── data.ts # loading data, publishing snapshots
│ ├── db.ts # Supabase clients (public read / service write)
│ └── auth.ts # PBKDF2 password, HMAC session cookie
├── app/
│ ├── page.tsx # ratings + RPI board
│ ├── team/[slug]/ # team profile
│ ├── admin/ # admin pages
│ └── api/admin/ # admin API routes
└── middleware.ts # guards /admin
These are the failure modes that produce plausible-looking wrong numbers rather
than an error. PROJECT.md covers the reasoning.
status column is informational and
the engine never reads it.maxWeekPlayed counts played games only. If it counted scheduled games,
loading a full season would erase the preseason carry-over instantly.7A reference
is stale 2025 code.podsFor in
src/lib/playoffs.ts encodes what the 2025 playoffs actually did: eight-region
classes pair 1-2, 3-4, 5-6, 7-8 in the first round, four-region classes cross
1-4 and 2-3. Ordinary bracket seeding then reproduces the AHSAA pairing
(A1-B4, B2-A3, B1-A4, A2-B3) on its own, which is why it is trusted for the
field sizes 2025 has no precedent for.TIE_RULES
in src/lib/tiebreak.ts. It is not a sort: the rule settles one place at a
time and refuses to rank the rest of a tied group until the top of it is
decided, and each factor narrows rather than orders. pickHighest is the
entry point for that reason. (q) is a coin flip, so the Index rating stands
in. Factors (k)-(p) count roster opponents only, because rule 7's
eligibility test for out-of-state schools is not something this data can
answer. Standings and the odds simulation both go through this, so they
cannot disagree about who holds a playoff place.QUALIFIERS
in src/lib/playoffs.ts is the only place to change it. 6A's 24-team field
is not a power of two, so region champions and runners-up take first-round
byes.src/lib/shade.ts. Both
arms of a diverging scale are strongest at their ends, so putting one on a
column with no line under it — seeds, rounds, the championship — spends
maximum ink on "no chance", which is true of most of a classification and is
news about none of it. It shipped that way: 0.1% to win the title drew 0.885
of the available colour and a real 9.4% contender drew 0.120. Only the
playoff column and a win-loss record have a genuine midpoint, so only those
two are ; everything else is and shades one way. The
invariant — a bigger number never gets less ink — is checked in
.npm run validate reproduces all 387 final 2025 ratings from the 2025 export
to a mean absolute error of 0.0046 — within the rounding of that file. That
confirms the composite step (step 5) and the SOS median.
It does not confirm the Massey solve itself (steps 1–3), which needs the
2025 game results to reproduce. npm run test:engine covers the invariants
above but is not a substitute for that diff.
Two things came out of the validation and are worth knowing:
sos_w 0.75 and wr_w 3.0.
The ratings that actually shipped used 0.90 and 6.0. The defaults in
src/lib/types.ts follow the data, not the document.sos > 0.polaritymagnitudenpm run test:playoffs(0.996 * 100).toFixed(0) is
"100". A probability display that rounds to whole percent will print a flat
100% for a team that is not in, and it never reaches whatever check guards
the word. label() in src/components/OddsBoard.tsx keeps a decimal place in
the top and bottom bands for that reason, so 100 and 0 can only be printed by
a team clinched, eliminated or settled arithmetically. The same trap
applies to any new percentage column.(W + T/2) / (W + L + T), not W / (W + L) — leaving ties out of the
denominator lets three ties carry a 2-2 team past a 3-4 one. standingPct in
src/lib/tiebreak.ts, called by orderRegion. It shipped once as
standingKey, which nothing called: orderRegion had its own copy of the
formula inline, so the fix sat in dead code while the live path went on
ignoring ties, and the test passed because the case it used gave the same
answer either way. Any test of this has to tell the formulas apart — 2-1-3
is .667 one way and .583 the other, which puts it either side of a 3-2 team.
Alabama settles games in overtime, so in practice this guards against a 0-0
entered by mistake, but standings, region order and the odds all read it.computeBoard (src/lib/board.ts:44) picks between
engine.ts and engine-twoway.ts on config.model, so a row field set by
only one of them is present or absent depending on a setting. That is how
postseason_ineligible shipped invisible for a release: it was added to the
classic engine, the live config is two-way, and every publish rebuilt the
snapshot with the ban stripped. npm run test:ineligible now runs both
engines and asserts shape parity between their rows — add a field to one and
it fails.defaultStatus
in src/lib/bracket.ts therefore returns a default and never a judgement:
Medium for everyone, bar a barred team and one that is mathematically out.
It used to map the chance of qualifying onto High/Medium/Low and set
Clinched from TeamOdds.clinched, which made every team in Class AA read
Clinched from the opening whistle.placeRange in src/lib/bracket.ts
reconciles them, and regionSettled in playoffs.ts additionally requires
that the region has actually played: "nothing left to play" is otherwise
indistinguishable from "no schedule loaded", and a class awaiting its
fixtures would report every place decided.{region: 4, place: 2}
and who that is gets resolved at render time from the standings. That late
binding is the whole design: re-seed a region and every slot follows with
nothing edited, which is why the hand-made arrangement imported from the old
site survives a season of results. Never regenerate slots from
defaultSlots — the live arrangement differs from every template (6A is laid
out across regions rather than by block; AA overrides the cross-seeding).r2g0 is the first game of the second round, so
anything stored against an id — a kickoff time, a projected winner — lands on
a different match-up if the bracket changes size. The admin API refuses a
save that changes a class's slot count for exactly that reason.order set stops following the season until someone clears it, and by
request nothing marks it on the public page. So the admin marks it, shows
what the season would have computed, and counts them in the header. The
importer deliberately does not pin by default: turning every disagreement
into a pin at import time would start the season with dozens of them, none
deliberate.season.ts, once
in playoffs.ts feeding four separate loops — so it now lives once, in
src/lib/eligibility.ts, and everything asks it. Note countsForTiebreak is
not the negation of countsForRegion: a game against a banned team is
dropped, not demoted to a non-region game, because "non-region" is a
category factors (k) and (l) actively read. A banned team is also removed
before orderRegion runs — left in, its 0-0 record scores .500 and sorts it
above everyone with a losing record.resolveBracket applies opts.projected and nothing else;
showProjections is reader visibility, and src/app/bracketology/page.tsx
withholds the projected bracket entirely rather than resolving it and hiding
it in the UI — a resolved bracket handed to a client component is serialized
into the page. The admin editor always resolves projections, because that is
where they are made.splitLeadingDate — so no measurement has to be right for a row to be
read.forfeit_by names which
side gave a game up; the scores stay as played. Everything record-shaped
(standings, region order, RPI, Résumé) reads the ruling, and the Index reads
the field — including the win-rate term, which is part of the rating rather
than a display column. See src/lib/result.ts.