- botFixed: a recap could be rejected for a comma. The grounding check reads every number in a draft and refuses to post one it can't trace back to the facts — it's the only thing standing between the model and a public channel, and it's why an invented final score can't reach your league. But it read *"1,500 rushing yards"* as two numbers,
1 and 500, so a figure taken straight from the record book was rejected as invented. Because records are stored as plain numbers, any figure over a thousand written the way a person would write it could never pass. Both of Storylines' real failures were this, and both were otherwise good recaps about a running back chasing a school record. They validate clean now, and a number that genuinely isn't in the facts is still blocked.
- bot
/help answers the person asking. It was one flat wall of text listing every command to everyone, so a coach had to work out which half applied to them and a commissioner had to do the same in reverse. It's now an embed that knows whether you're a commish, leads with the weekly rhythm — react ✅, use the buttons in your game thread — and carries links to the right guide for you, the full command reference, the web app and the support server.
- webThe getting-started checklist now shows up for Discord leagues. It was gated to solo dynasties, so the leagues with the steepest drop-off were the ones that never saw it. Commissioners get their own four steps — members, cadence, this week's games, first stat upload — matching the pinned control panel in Discord, so it's the same list in the same order wherever you look.
- docsSeven shipped
/dd commands were missing from the command reference, including /dd board, which the setup guide tells you to run. Deep links to the commish-filtered command list now actually filter.
- botFixed: a quiet week permanently blocked its own recap. Storylines reserved its database row *before* checking whether the week had any completed games — the reservation is a lock, so two callers can't both pay for a generation. A week with nothing to write about then got that lock stamped failed, and because the lock is one-per-week, that week could never produce a recap again: log the games late and Storylines would decline forever. 15 of the first 19 recap rows were this, which is why the feature looked like it was failing 89% of the time when it was really being asked to write about weeks with no football in them. The check now runs first, those 15 dead rows are cleared, and the weeks they were sitting on are free again. Real success rate on genuine attempts: two posted, two failed on validation.
- botBeing given the league role now welcomes you. A coach who joined the server and got the member role heard nothing at all. They now get a DM — how the weekly loop works, and the one command that puts them on the roster. If a commissioner already added them, it's the full welcome with their dashboard link instead. (Only applies to leagues that use a member role.)
- webFixed: a deep link into the app forgot where it was going.
/join?league=…&next=/stats carried the league through the Discord sign-in but dropped the destination, so anyone not already logged in landed on the dashboard instead of the page they clicked. Both now survive the round-trip.
- botBeing added to a league now tells you you've been added.
/roster add replied only to the commissioner who ran it, so the coach found out by being told out of band, or not at all. They now get a DM naming their school, explaining the status board and the ✅ react, pointing at their game thread's buttons, and — the part that has never existed — carrying a link straight to their dashboard. The web app has had a /join deep link the whole time and the bot has never once generated one, which is most of why exactly one member in 240 has ever opened it. The commissioner is told whether the DM actually landed, so they can pass it on if the coach has DMs closed.
- botFixed: links from the bot pointed at a hostname that doesn't exist.
app.dynastydesk.gg has never had a DNS record, and it was the fallback every bot-built link used whenever APP_URL was unset — which it is everywhere. Scheme prep DMs, the new-season post and the draft board have been sending people to a dead host; v1.51.0 then routed the rest of the bot's links through the same helper, so it went from three places to ten. They all point at web.dynastydesk.gg now, which is where the app has always actually been.
- botSetup now leaves something behind.
/dd setup created a channel, locked it so members couldn't chat, replied with a four-step list — and posted *nothing into the channel it had just made*. The commissioner's only evidence anything had worked was an ephemeral message that vanished on refresh, and nobody else in the server saw a thing. It now posts the status board (which it was already wired to do and simply never called — hence "run /dd board" sitting there as step 3) and pins a control panel: the season, the commish, the next deadline, the weekly commands, and buttons for what's left to do. Its getting-started checklist is read from the database rather than remembered, so a step done on the web ticks itself off, and the whole checklist disappears once the league is running. Every league that already exists gets one posted automatically.
- botGame threads have buttons. *Schedule this game*, *Log the score*, *Mark as played* — in the thread, on the game they're about. Scheduling is two menus (a day, then a time) rather than three typed options; logging a score opens a box for the numbers. The command list stays underneath for anyone who prefers typing, and both routes run exactly the same code, so a result logged either way behaves identically.
- botEvery command DynastyDesk mentions is now tappable. Discord renders a real command mention as a chip that opens the command with its options ready — the bot was writing them as plain text you had to retype by hand, which on a phone is most of the reason a posted command list goes unused. Game threads,
/help, the setup steps and the "not set up yet" prompt all use them now.
- botAdding the bot no longer posts in your server. It DMs whoever added it instead — nothing at all is shown to your members until a commissioner runs setup, and the DM says so plainly, along with exactly what setup *will* make public and what stays private. Setup's confirmation and the pinned panel now spell out the same split, so it's never a guess whether an action was visible to the league.
- botFixed: a game-thread button could act on a game you weren't playing in, and logging a score from an older week's thread wrote it against your current game. Both are now scoped to the game the thread is about, and to you.
- botFixed: the bot pointed at two different web addresses depending on which message you were reading.
- db
AuditLog.leagueId is now optional, with indexes on [leagueId, eventType, createdAt] and [createdAt]. League.controlPanelMessageId and League.onboardedAt track the pinned panel.
- botCommand usage is now recorded — which commands run, from a slash or a button, and whether they worked. Nothing about what you typed is stored: command names and outcomes only, never option values, message text, or the contents of an error. Set
DD_AUDIT_COMMANDS=0 to turn it off.
- webAn offseason realignment now lands on next season, not the one that just ended. Conference records aren't stamped on a game when you log it — they're worked out from who shared a conference in the year it was played. So a realignment saved at the current dynasty year applies *backwards* through that season as well as forwards. That was worst in exactly the window the app pointed you at: during the offseason the dynasty year is still the season that just finished, so the League banner and the pre-rollover checklist both sent commissioners to realign "for next season" and quietly re-scored the completed one instead — including the conference record about to be frozen into Season History at the rollover. Realignment saved during the offseason is now staged for the following season, which is what the banner and the checklist were already promising.
- webThe Conferences page says which season it is about to change. It reads *"Takes effect in 2027"* and shows the alignment for that year, so what's on the board is what a save writes. Realigning a season that's already underway is still allowed — that's how you fix an alignment you got wrong — but it now warns you first, names how many completed games it will re-score, and asks before saving anything.
- web"Best at each position" reads like a depth chart. All twenty-three positions sat in one flat two-column grid, so finding your secondary meant scanning every row. It's now split by unit — offense, defense, special teams — and the line separated from everyone else inside each: skill and offensive line, then defensive line, linebackers and secondary. Groups with nobody rated are dropped instead of rendering as an empty heading.
- webThe "Biggest drops" board is gone from the Roster page. Overalls in CFB never regress — Training Results can leave a player flat but never moves him backwards — so the board had no real football to report. Everything it ever surfaced was a position change (a receiver who moved to corner is rated on a different scale) or a typo in a re-imported roster, presented as if the player had declined. A drop still shows on an individual player's timeline, where the position change sits next to it and explains it.
- db
PortalEntry.starRating — the recruiting-class stars for an incoming transfer. ScoutingEntry.fifthYear — a senior's flagged extra season. Recruit.class and Recruit.redshirt are deprecated; nothing reads them any more.
- webAn imported recruiting class now arrives signed. *Top Classes → Details* is the class you actually landed, but every recruit read off it was filed as targeting — and only signed and committed recruits are promoted at the rollover. So an imported class sat one bulk edit away from being usable, and if nobody made that edit it was archived at the season reset instead of joining the roster. The importer files the batch as signed and still offers the picker if you want otherwise. The spreadsheet importer follows the same default.
- webA high-school recruit is a freshman, and the board stops asking. Class and Redshirt were two fields on the HS board that a high-school recruit never has. Both are gone from the add form, the row editor and the table, and both promotion paths hardcode FR rather than trusting a column, so a stale value on an older row can't put a signee on your roster as a sophomore.
- webA prospect you didn't sign now ages on the scouting board. He signed somewhere — Signing Day resolves the whole board — so next season he's a true freshman on another roster, and that's the clock the board runs on. He used to land with no class at all, which meant he never advanced and never graduated off: a permanent resident of your watch list. Now it's FR → SO → JR → SR, and "he's a JR now" is the signal that he's a realistic portal target.
- webAnd he gets a fifth season, because the board can't see a redshirt. The board follows someone onto another team's roster and never learns whether he redshirted there — so retiring him the moment his senior year ended dropped every redshirt player exactly one year before the fifth-year portal move that made him worth watching. A senior now rolls into one more flagged season, shown as SR+ in amber with *may have graduated* beside it, and a tooltip that says plainly he's only still eligible if he redshirted somewhere you couldn't see. There's a 5th checkbox on the row if you know better either way.
- webIncoming transfers can be given an archetype and a star rating. Archetype was stored on a portal entry and read off the player card by OCR, but there was no control anywhere to set or fix it. Stars had no column at all — the screenshot reader counted the glyphs and then stuffed *"4★"* into the notes field, which is where the note in the code said it would live until there was somewhere better. Both are now editable on the add form, the board row and the screenshot review, stars show on the incoming board, and both carry to the scouting board at the rollover. Changing a position clears an archetype that no longer belongs to it.
- docsThe setup guide is now four guides, and there's one link to send a new commissioner. It was a single 136KB page covering the player quick start, commish setup, seven Discord workflows, ten web app pages, 76 commands and the FAQ — and on a phone it had *no navigation at all*, because the sidebar was hidden below 768px. It's now a hub at
/setup.html routing to [Get Your League Running](https://dynastydesk.gg/start.html) (the commissioner walkthrough, in three phases), a player guide, a web app guide, and the command reference. The on-this-page nav is a collapsible control on mobile instead of being hidden.
- docsThree things the old guide got wrong. It said setup posts the status board immediately — it doesn't, you run
/advance start, so anyone following along saw an empty channel and assumed they'd broken it. It said nothing about the DynastyDesk channel deleting chat, which applies to an existing channel you point at exactly as it does to the one the bot creates: aim it at #general and your league's conversation starts disappearing. And setting up mid-dynasty — fully supported, via starting_week and starting_year — was one clause buried in a step, so people were asking whether it was possible at all. All three are now stated plainly, the last one under a heading that asks the question.
- bot
/help now links the written guides. It pointed only at the web app and the support server, so a commissioner who'd just added the bot and typed /help got no route to the setup walkthrough.
- webThe AP rank field on an imported box score is now readable. It rendered its *value* in the same grey as placeholder text, on a transparent border, with no label and a box too narrow for the word — while the team name directly above it had a visible border. A rank the scoreboard reader got wrong was therefore nearly invisible at review, which is the one moment it could be caught. The two states are now plainly different from each other: a ranked team reads as a bold #12 on a solid underline, an unranked one says Unranked on a dashed one, and the card header spells out that ranks are optional. Manual entry gets the same label and placeholder. Pasting "#12" into the field now works —
parseInt treated the leading # as junk and silently cleared it, which mattered more once the field started displaying ranks that way. The 1–25 rule now lives in one module instead of once in the editor and once in storage.
- db
Matchup.bowlName — which bowl a postseason game is, recorded once on the game itself.
- webBowl games can be named when you schedule them. Bowl Weeks 1–3 give the Set Schedule and Edit forms a bowl picker: the CFP rounds, the New Year's Six and thirty other bowls, plus "Other…" for anything the list doesn't carry — EA's lineup moves between releases, so a closed list would eventually make a real bowl impossible to record. Bowl Week 4 has no picker at all, because the national championship is the only game played that week; a game scheduled there is labelled the title game automatically, and a client that says otherwise doesn't get to relabel it. Which weeks are which is derived from
derivePostseasonWeeks, the same function the Dynasty Cup scores off, rather than a third hand-copied list of week numbers. The name shows on the schedule row in place of the generic *Neutral* badge, which a bowl is by definition. Season History reads it too rather than asking a second time: an empty Bowl / round field prefills from the matchup and says where it came from, the same way that page already sources staff from the Coaching Staff page instead of inviting a second unlinked copy.
- webAn unranked opponent can be recorded as unranked. The box-score review screen coerced every number with
parseInt(raw) || 0, so clearing a rank stored 0 rather than null — and there was no way to say "unranked" at all. Importing a scoreboard where OCR read an unranked team as #1 left a choice between a wrong rank and a rank that doesn't exist, and the 0 reached GameResult and rendered on the dashboard as "(0)". Ranks now accept 1–25 and treat blank, 0 and junk alike as unranked, on every path that reads or writes one. Existing rows that already hold a 0 stop displaying as a rank without needing to be edited.
- ocrThe scoreboard reader no longer invents a rank out of a stray glyph. The team block is parsed as "12 GEORGIA", and any leading digits were taken as the AP rank however implausible — so a logo edge or separator read as a digit ranked a team that wasn't ranked. Digits outside 1–25 are now dropped and the school name is still recovered.
- webThe dashboard schedule's NOW chip and the Add Stats link sat flush against each other; they now share a spaced row.
- webPlayers Leaving is now the week your coaching staff changes hands. The offseason roadmap gains a *New Coordinators Start* bullet on week 24, and the app finally behaves as though it means it. Coordinators are fired in week 18 and hired across the bowl weeks, but the people you hire don't take their seats until Players Leaving — while DynastyDesk's
dynastyYear doesn't tick over until the rollover eight weeks later. In that gap a coordinator you'd fired went on stacking their bonuses onto the board for the whole offseason, through the portal, National Signing Day and Training Results. From week 24 a recorded departure takes them off the board, and the Coaching Staff page switches to next season's staff: coordinators you kept stay put, and an empty seat says who left and asks you to add whoever replaced them. The dashboard's staff card prompts for both — record the hire, and update the skills of the ones you kept. Anyone you add now is filed against next season, which is where the rollover was going to put them anyway, so recording early and rolling over later no longer disagree.
- botThe week-24 status board and
/offseason list *New Coordinators Start* instead of "no required actions".
- webScheduling a game against yourself is now refused rather than saved.
/matchup set has always rejected it, and the dashboard's opponent list leaves you out of it — but the API behind that list never checked, so a stale page or a direct call could write a coach in on both sides. The row that produced is unplayable twice over: it waits forever on a confirmation from the person who submitted it, and it makes the advance count that coach as already scheduled, so they get no real game that week either. The check compares league members rather than raw IDs, because a coach can be identified by their Discord account, their web login, or both — comparing the IDs alone would let the same person through under their other name.
- botA coach who had already scheduled their own CPU game was left out of the week's CPU Games thread. The thread was built from the games the advance *created*, and the advance deliberately skips anyone who already holds a game for that week — so building your season ahead of time on the dashboard cost you the thread: no ping when the week opened, and no thread link against the game in
/games or on the web. In a league where everyone had scheduled ahead, no CPU Games thread was opened at all, on a week full of CPU games. The thread is now built from the week's CPU games rather than from whichever rows the advance happened to add, so it covers every unplayed CPU game however it got there — pre-scheduled, backfilled, or inherited when an opponent retired. Games already played are left out; nobody needs a deadline for a game they've logged.
- webCompleted CPU games show the opponent's logo again. A CPU opponent is stored two ways — an abbreviation when you pick the school on the dashboard, and free text when you pass
opponent_team: to /result log, which asks for a name and so usually gets "Florida" rather than "UF". The schedule table preferred the logged value and then looked it up as an abbreviation only, so logging a game from Discord replaced a name it could resolve with one it couldn't, and the crest fell back to a grey letter tile the moment the game was marked complete. Both forms now resolve, including the untidy ones ("Ohio St"), and they resolve in the order the record book already used — so the schedule and your season history can no longer name different opponents for the same game. A game logged against an FCS filler keeps the generic FCS badge instead of turning into "MIDW".
- webocrThe Scouting Big Board now records which school each prospect is at, which is the one thing it was never capturing. The board exists for players who are somewhere else, and it has had a Signed With column, and moving a prospect to the transfer portal has always carried that school across as the school they are leaving — the screenshot import simply never filled it in, so every imported row arrived blank and had to be typed in by hand. Scrolling other teams' departures during Players Leaving week and taking a card at a time now brings the school along with the player.
- ocrThe school is read from the team header above the table, not from the small pill beside the L2 button. On the Players Leaving screen the two agree, but on a recruiting class screen the pill shows *your* team while the class on screen belongs to someone else — so keying on it would have quietly stamped your own school onto every prospect in the import. Where a card states the school outright ("Levi has signed to Georgia"), that wins.
- ocrFixed a dev trait and a star rating being invented from the ability badges. The small star-and-letter icons beside the team logo at the top of a player card are ability badges, and a card from Players Leaving has no dev trait on it at all — but those icons were being read as a dev trait of *Star* and as a two-star recruit. Dev trait now comes only from a field actually labelled as one, and a star rating is only accepted where the national and state ranks that always accompany it are present. A wrong star rating is worse than a blank one, because the board sorts and filters on it.
- webThe import review table gained a School column, so a school that reads wrong — or one the screen never named — can be set before anything is saved, from the same school picker used everywhere else.
- ocrwebScouting board screenshots are now read by Claude vision rather than by fixed screen coordinates, because no fixed coordinates can be right for this screen. A batch of around eighty cards came back with most names wrong, not one correct position, and archetypes that were nonsense — and nothing about the rows said so. The reader mapped fixed fractions of the frame onto the player card, but that card is drawn on four different screens (the recruiting board, Top Classes details, the transfer portal, the scouting list), each of which puts it somewhere else, and its rows are conditional: a recruit carries a star row and national/state ranks under the name that a portal player does not, which shifts everything below it. Worse, the only thing that could trigger a second look was the *name* coming back empty, so a confidently misread position sailed through to the review table looking like a clean value. Every field on the card is now read from the card itself, in about three seconds each.
- webArchetype is a dropdown everywhere it can be set, instead of a free-text box in one place and missing from the other two. It was a plain text input in the import review table — so a misread archetype was stored verbatim and a correct one was a typing job — and the manual add and edit forms had no archetype field at all, which meant typing it during an import was the only way to record one. All three now offer the archetypes their position actually has, and the list of those archetypes is kept in one place rather than the two copies it had drifted into.
- webA prospect's overall can be read, corrected and stored. The review table showed OVR as read-only text, so a card whose overall was missed could not be fixed anywhere in the flow, and on import the value was written into the notes field as the words "OVR: 82" — where nothing could sort by it, filter on it or edit it, even though the scouting board has had a real overall column all along. It is now an editable cell that writes to that column, and the manual forms carry it too. Star rating and national and state rank come off the card in the same pass.
- ocrTwo misreads worth naming, because both looked plausible enough to survive review. "Elusive Route Runner" came back as "Exclusive Route Runner" — a real phrase, one letter out, which no amount of tidying up afterwards can catch; the archetype is now picked from the position's own list rather than transcribed freehand, and anything that still doesn't match is left blank for you to set rather than stored wrong. And a roster card's ability icons were being counted as a one-star recruit rating; a star rating only means anything next to the national and state ranks, and the reader now knows that.
- webScouting screenshots are read one at a time, so a dropped connection costs one screenshot rather than the whole upload. Eighty cards went up in a single request — the same failure the roster upload had in v1.44.3 — which sat on one connection long enough that a phone browser would give up on it and take every card already read with it. Per-file progress, one automatic retry, a Retry N Failed button and a Skip N and continue path, all as the roster now works.
- ocrFixed a decode that ran once per region instead of once per screenshot. Decoded screenshots are cached so every field on a screen shares one decode, but the cache stored the finished image rather than the work in progress, so the several reads a screen starts at the same moment all missed it and each decoded the file again — about 33MB and a full decode apiece, seven times over for a single player card.
- ocrScreenshots sent for a vision read are now scaled down first, to exactly the size the service scales them to anyway. Same reading, same cost, roughly 750KB uploaded per screenshot instead of four megabytes — which on a large batch, or a phone, is the difference between finishing and timing out.
- webRoster screenshots are now read one at a time, so a dropped connection costs one screenshot instead of the whole upload. Every screenshot used to go up in a single request. A full roster is six to eight screens, each costing 20–60 seconds of Tesseract plus a Claude-vision pass, so the upload sat on one connection for anywhere from three to ten minutes — and mobile browsers do not tolerate that. Switching apps while waiting is enough to drop it. When it dropped, every screenshot already read went with it and the entire roster had to be read again from scratch, which is exactly what it looked like from the coach's side: a long wait, then nothing.
- webA dropped screenshot is retried once automatically, and a Retry N Failed button re-runs the rest on request. Screenshots that already read keep their rows and are never sent for OCR a second time — the same treatment the bulk stats upload got in v1.44.2, now applied to the roster, with per-file progress and a player count against each file as it lands.
- webA screenshot that can never be read — a menu, a stat page, a capture too small to resolve — is marked as such and left out of the retry, with the reason shown against the file rather than a bare ✕. Skip N and continue carries on with the players that did read instead of forcing the whole upload to be abandoned, and the skipped screenshots are named at the review step so the missing players are accounted for rather than quietly absent.
- webOne unreadable screenshot no longer fails the entire upload. The read loop had no error handling around each file, so a single image that threw took down the whole request and discarded every screenshot already processed with it.
- webGoing Back from the review table no longer costs a second OCR pass. It returns to the file list with every screenshot's rows still in hand and continues straight back to review.
- webClosing or leaving mid-upload now warns before discarding screenshots that have already been read, including when picking a new selection would replace them.
- webFixed a memory leak in the roster, transfer-portal and recruit-card upload routes. Decoded screenshots are held in an in-memory cache for the duration of a read and are meant to be released when it ends; these three routes never released them. That is roughly 33MB per 4K screenshot — about 230MB for a single roster upload — retained for the life of the server process and accumulating with every upload after it.
- webA screenshot that fails to upload can now be retried on its own, instead of forcing the whole batch through OCR again. Every batch upload screen — bulk game stats, record submissions, admin record import — marked a failed file with a red ✕ and then offered nothing further. Two dropped connections in a twelve-screenshot game-stats upload meant clearing the list and re-reading all twelve, and a real upload took three attempts to get through that way. A Retry N Failed button now re-runs only the files that failed; everything already read keeps its result and is never sent for OCR a second time.
- webA dropped connection is retried once automatically before it is ever shown as an error. When a request never comes back — the connection drops, or a gateway returns its own HTML page instead of the route's JSON — the screenshot is simply sent again, so the most common failure now resolves itself without the batch appearing to fail at all.
- webFailures are told apart so a retry doesn't waste OCR on one that cannot succeed. A screenshot the parser doesn't recognise, a non-stat screen, or an expired session is marked permanent and left out of the retry — re-running those costs a full Tesseract pass plus a Claude-vision call to arrive at the same answer. An error the server reported itself is retryable on request but never automatically, since an immediate re-run usually fails the same way and doubles the load.
- webFailed rows can be removed individually. Previously only files still waiting had a ✕, so a screenshot that would never parse stayed in the list until the whole batch was cleared.
- botwebA retired coach's games are now treated as CPU games whenever they left, not only if they left at the right moment. Retiring has converted a coach's scheduled user-vs-user games into CPU games for their opponents since v1.43.0, but that ran once, at the moment of retirement, and only over the games that existed right then. Anything else slipped past: a coach retired before that feature shipped, a full-season schedule imported after someone had already gone, a
/matchup set naming a member who had left. Those rows sat there permanently pointing at somebody who was never coming back — and /result log against one would cheerfully write the result pending and promise to DM a coach who could not confirm it. Retirement is now re-checked wherever it matters instead of being handled once and forgotten.
- botEvery advance now sweeps the league for user-vs-user games still naming a retired coach and hands them to the surviving opponent as CPU games, announcing it in the channel for games in the week that just opened. This is the fix that matters most for the *opponent*: the advance backfills a CPU game for anyone not already scheduled, so a coach stuck in one of these orphans was skipped and got no game at all that week — silently, every week.
- botweb
/result log and the dashboard's result form convert the game before writing, so the fix lands the moment someone notices rather than at the next advance. A result that goes through this way says so: *"Your scheduled opponent has retired, so this counted as a CPU game — no confirmation needed."*
- botwebBox-score uploads do the same. A box score finalises a game outright rather than waiting on the other coach, so uploading one against a departed coach used to freeze the row as *completed* head-to-head history — worth Cup points, a phantom loss on the retiree's record, and a head-to-head beat in the weekly recap. Both OCR paths and manual entry now convert first.
- botwebA result left
pending against a coach who has since retired is no longer treated as a played game and skipped. It was the opposite of a played game — a result waiting on a confirmation that could never arrive — and refusing to touch it was what made these games unfixable by simply re-running the conversion. Such a result now comes across with the game and is confirmed. If the *retiree* was the one who logged it, the score line is re-expressed from the surviving coach's side rather than handed over backwards.
- dbGames that were genuinely played are still never touched: a confirmed or disputed result, a completed matchup, or an attached box score all leave the row alone, and a game between two coaches who have both retired is left as-is since it strands nobody.
- ocrThe Claude-vision fallback now actually runs on roster imports — it never could before. The feature shipped in v1.9.0 and has been reachable from exactly two places since: the bot's
/stats submit and the single-game web upload. Every other screenshot path — roster, players leaving, recruit cards, the record book — called the parser directly and returned before the fallback was ever consulted. A roster import could not reach Claude no matter how it was configured, which is why no usage ever showed up for one.
- ocrRoster and Players Leaving rows now carry per-cell OCR confidence. Both screens read their cells through a helper that threw the confidence score away, so even once the fallback was wired in it had nothing to judge a cell by and would have corrected nothing. Neither screen's parsed output changes — verified against the committed golden fixtures.
- ocrA field is re-read when Tesseract is unsure *or* when the value it produced looks wrong. These catch different failures and the second one is why the roster needed more than a one-line fix: this game's font draws "7" almost like "1", so a real 74 overall comes back as 14 at ~98% confidence — Tesseract is certain and wrong, and no confidence threshold will ever flag it. The parsed-value heuristics catch that; confidence catches the smudged reads the heuristics think look fine. On a real screenshot the two between them recovered a highlighted row's missing year and overall *and* fixed "J.Willlams" → "J.Williams", which was only ever going to trip the confidence test.
- ocrExtended the fallback to Players Leaving, recruit cards and the record book. A recruit card with an unreadable name is discarded as a failed upload, so that one is the difference between recovering the prospect and losing the screenshot. On Players Leaving it only spends a call when a *name* or a *reason* is doubtful — position and overall come from a roster lookup there and are ignored, so a misread overall isn't worth paying for.
- ocrAdding a screen to the fallback is now one entry in a registry rather than an edit to the reconciler, and screens no longer get a stat-table prompt regardless of what they show. Corrections re-derive anything computed from them — a corrected "leaving" reason recomputes whether that player defaults to being imported, instead of changing the label and leaving the behaviour behind it alone.
- ocrThe fallback no longer spends an API call when it has nothing to re-read, logs a line when it corrects something, and still never overwrites a confident read or writes a blank over a value. With no API key set it remains a no-op.
- webRoster review rows no longer jump while you fix a name. The screenshot-import table re-sorted on every render, so the sort key moved with each keystroke: correcting a misread name walked the row up the list one letter at a time, out from under the cursor. Because a browser drops the caret when a focused field is moved, the rest of what you typed could go nowhere and leave a half-finished name you believed you had finished. Nothing was ever mis-assigned — a row is a single object addressed by its id, and the import has always sent the full list rather than whatever happened to be on screen — but a row that bolts away mid-edit reads exactly like data going somewhere it shouldn't. The order now settles once, when the screenshots are read, and moves only when you ask it to: a Re-sort by name button appears once editing has actually pulled a row out of alphabetical order.
- webThe same edit-and-vanish problem on Update Overalls. With "players I haven't confirmed" ticked, typing the first digit of an overall dropped the row out of the list mid-entry — leaving a one-digit rating behind on a player you could no longer see. The filter now holds whichever players were unconfirmed at the moment you ticked it, so rows stay put while you work down them; unticking and reticking re-takes the list.
- webGoing Back and re-reading screenshots no longer lands you on an empty review table. "Show only flagged" kept its selection from the previous read, and those rows no longer exist, so it matched nothing and hid everything.
- webFixed manual stat entry adding a new game every time you saved instead of updating the week's. Both screenshot paths — the web uploader and the bot's
/stats submit — have always replaced your existing submission for a week when you re-send it. Typing stats in by hand never did: it inserted a fresh game each save. Because the form pre-fills each tab with the players you logged last week, a coach working through it tab by tab would save four times and get four rows for one game — one carrying the score, one the passing line, one the rushing line, and two holding nothing at all. Season totals summed all of them. Saving now replaces the week's game, and opening the form loads back whatever you already saved for the selected week, so correcting one number no longer means retyping the rest.
- webRows pre-filled with a player's name but no numbers are no longer saved. They became empty stat lines that showed up as blank rows in a player's game log, counted toward his games played, and could mint a roster entry for someone who never took the field.
- webEditing a week that was submitted by screenshot keeps the detail the manual form has no field for — passer rating, longest play, broken tackles, the whole blocking category. Figures computed from something you changed, like completion percentage, are cleared rather than carried, since a stale one reads as fact.
- dbOne-off repair for the games already split across several rows:
scripts/dedupe-game-stats.ts, dry-run by default. It merges rather than deletes, because the duplicates aren't copies — each holds a different part of the game. Every row is folded onto the earliest submission for that week, redundant lines are field-merged with the later save winning a disagreement, and anything two submissions genuinely conflict on is reported instead of being resolved quietly.
- webAdded a Delete control to the game log. Its only repair tool was Move, which can re-tag a game but never remove one — so anyone already holding duplicate rows had no way to clear them. Weeks with more than one game logged are now flagged above the table and highlighted in it.
- webbotA new season no longer starts with an unexplained blank roster. When the dynasty year turns over, every player's overall is cleared on purpose — it is the one number that genuinely changes every season, so carrying it forward would just show you a stale figure dressed up as a current one. Nothing said so, which made the reasonable conclusion "my data was lost". The subtler half was worse: the three fields that *do* carry forward — position, archetype and dev trait — arrive as last season's values in exactly the same styling as something you typed yourself, even though positions change at National Signing Day and dev traits change at Training Results. So a new season opened with one field conspicuously empty and three quietly out of date. There is now a dashboard card on both sides of that seam. From National Signing Day through the final week it lists what to lock in before the year turns; through Preseason and Week 0 it explains the cleared overalls and points at the one screen where all four fields get confirmed together. It sits above the Offseason Roadmap and stays out of its way — the roadmap lists what to do in the game, the card lists what to do in DynastyDesk. Discord gets the same courtesy: a heads-up two advances out, timed to Training Results so that recording dev traits *then* carries the new values into the new season instead of last year's, and an explanation after the rollover next to the existing recruiting note. The card is dismissible per season and per side, and retires itself once its checklist is clear.
- webUpdate Overalls now edits dev traits, and can fill the blanks from last season. Dev trait was display-only on the one screen built for the once-a-season refill, so recording a squad's Training Results meant going through the full roster table instead — which saves one player per request. It is now a column beside position and archetype, and all four fields save in a single request. A Start from last year button seeds every empty overall with last season's value, turning a seventy-row retype into a seventy-row correction; it never touches a figure you have already typed, and nothing is written until you save. Carried-forward fields now show what they were whenever your value departs from it, so a season's edits read as a visible change rather than disappearing into data that looks identical either way. The filter is relabelled "players I haven't confirmed", which is what a missing overall has always actually meant.
- webThe game's four dev traits had been declared separately in ten places, which let the validation list and the colour ramp drift apart unnoticed. They now come from one module, as do the rules for validating a player's season fields.
- botwebRetiring a coach now hands their unplayed games to their opponents as CPU games. Retiring only ever flipped a flag on the member, so any user-vs-user game already on the schedule kept pointing at someone who had left — and it was the *opponent* who paid for it. The weekly advance skips creating a CPU game for anyone already named in a user game, so that coach got no game at all that week; and if they logged a result anyway it sat waiting forever on a confirmation from a player who was gone.
/roster retire and the dashboard's Retire button now convert those games in place: the remaining coach becomes the only player on a CPU game, keeping their home/away side, and is told in the matchup thread. Games that were actually played are never touched, and a game that was stuck awaiting a forced win stops blocking the advance. Unretiring restores the member but not the schedule.
- botFixed the advance handing someone two games in one week. The CPU-game backfill only skipped members who had a *user* game that week, so anyone already holding a CPU game — one scheduled from the dashboard, or one just converted from a retirement — got a second one created on top of it. It now skips anyone with any game already on the books for that week.
- webAdding a player to your roster now takes them off the recruiting board. Signed recruits and committed transfers stayed listed after being added, so a coach who filed their whole transfer class was left staring at a full board with nothing left to do on it — and no way to tell who was still outstanding. They now come off the board the moment they join the roster, exactly as they would at the season rollover, and the header keeps a running "N on roster" count so an empty board reads as finished rather than lost. Players added this way stay visible under the season picker once the year turns, marked *Signed → roster*.
- dbOne-off cleanup for boards where players were added before this shipped: those entries were linked to a roster player but never cleared, leaving them stuck on the board.
scripts/backfill-promoted-board-rows.ts, dry-run by default.
- webFixed the HS Board and Transfer Portal failing to load. Both pages shared a helper module with the season-rollover code, and that module imported the database client — so the moment the boards' own on-page controls imported the harmless half of it, the database client was bundled for the browser and the pages died on open. The Scouting Board was unaffected because it imports nothing from that module, which made this look like a data problem rather than a bundling one. The shared helpers are now split from the database-backed ones.
- ciAdded a test that walks every client component's imports and fails if any of them can reach server-only code. This class of bug passes the build, passes typechecking, and only shows up when someone opens the page.
- ciFixed the web app failing to deploy since v1.42.0. The new shared recruiting package was imported by the web app and the bot but never declared in either one's
package.json, so the Docker image — which installs from the lockfile and a handful of manifests rather than linking every workspace — had no module to resolve. Everything passed locally, where a root install links every workspace regardless, and the build failed only on Railway. Nothing from v1.42.0 or v1.42.1 was actually live until now, which is why the school dropdown and the rollover fixes hadn't appeared.
- ciAdded a test that fails when an app imports a shared package it hasn't declared, checking both
package.json and the lockfile. The Dockerfile has warned about this in a comment for months and it has now cost two red deploys; a comment can't fail CI.
- webFixed transfers and recruits being filed a year late. Adding a committed transfer to your roster always put him in *next* season — right if you're in the offseason building that roster, wrong if the year has already rolled over and you're entering the arrivals for the season you're playing. He'd sit invisible for a full year. Which season a signing joins is now decided by where your league is in its calendar: the offseason weeks build next season, everything from Preseason to the National Championship adds to the one in progress. Same fix on the HS board.
- webAdded "Add all to roster" on the Incoming tab. A scanned transfer class arrives a dozen committed players at once and none of them reached the roster until you clicked through them one at a time. There's now a single action for everyone still waiting, and it names the season they're joining.
- webDropped the Dest. column from the Incoming portal table — an incoming transfer's destination is your own team, so it was a blank stripe on every row. Still on the My Players tab, where it says where your departures went, and still editable on an incoming player for the case where they signed elsewhere.
- webbotThe new league year now does something to your recruiting. Rolling into a new season used to leave the HS board and the transfer portal exactly as they were — so last year's class sat there under the new year's heading, players you'd signed never arrived on your roster, and anyone who transferred out kept aging a class a year on a team he'd left. At the rollover: signed and committed recruits and committed incoming transfers join your roster at the right class, everyone uncommitted moves to your Scouting Board, players who left through the portal come off the roster (and get tracked on the Scouting Board in case you want them back), and both boards start the new season empty. A player you persuaded to stay is left alone. The Discord advance announcement says what moved.
- webLast year's class is still there. The HS board and Transfer Portal have a season picker, and a past year shows every row with an Outcome column — Signed → roster, Moved to Scouting, Departed, or removed by hand — so you can see where a class ended up rather than just losing it. Both boards warn you what the rollover will do from Signing Day onwards.
- webIncoming transfers have a Class. The portal board never had anywhere to record what year a transfer was, so promoting one guessed. There's now a Class field with a redshirt mark on both the Incoming and My Players tabs, filled in automatically by the roster picker, the Players Leaving reader, and the transfer class screenshot reader shipped in v1.41.0 — which was already reading the eligibility year off the screen and keeping it as a note for want of a column. It's the class as the game shows it, meaning the season just finished, so joining your roster advances it a year: a portal SO arrives a JR.
- web"From School" is a dropdown of the game's teams instead of a free-text box, as are "Signed With" on both the portal and the Scouting Board. A value that doesn't match the list is kept and marked
(unmatched) rather than thrown away, so nothing you've already typed is lost — pick the real school to fix it. Stops "Oregon St" and "Oregon State" being two different places.
- webFixed a recruit signed on National Signing Day starting his first season as a sophomore. Promoting from the HS board wrote the player into the season that was *ending*, so the rollover's class advance then fired on him. Promoting now files him under next season, and says so. It also stops hardcoding every signee to freshman, carries his overall and redshirt mark across, and links him back to the recruit so the rollover doesn't add him a second time. Two recruits with the same name and initial no longer silently collapse into one player.
- webThe Scouting Board shows a real overall for entries that have one, and where each came from — your HS board, the portal, or a player who left you — since it now fills itself every rollover instead of being hand-curated.
- dbAdded
PortalEntry.class, .redshirt and .outcome; Recruit.playerId and .outcome; ScoutingEntry.ovr, .sourceType, .sourceId and .addedYear, with a unique index on the source pair so a repeated rollover updates an entry instead of duplicating it. Pre-advance snapshots record the recruiting columns the rollover now changes (payload v2).
- db
scripts/backfill-recruiting-rollover.ts repairs leagues that already crossed into a new year before this shipped — dry-run by default, snapshots before writing.
- webScan your transfer class onto the Incoming board. The portal board's Incoming tab had a "Scan a screenshot" button that opened the *Players Leaving* reader — the departures screen — so it could only ever tell you about players going the other way. It now reads the screen arrivals actually appear on: Recruiting → Top Classes → Details, on the Transfers list. Both halves of that screen are read from the same capture — the table down the left for everyone in the class, and the highlighted player's card on the right for his full first name and archetype — so either one on its own is enough, and a full-screen shot gets you the class with one player filled in. Everything stays editable before it lands on the board, and if you captured the Overall list by mistake you're told so rather than quietly filling the portal with high-school recruits.
- ocrFixed the CF27 recruiting player card reading labels instead of values. Its regions had been borrowed from the roster card on the assumption the two were the same panel. They aren't — the recruiting card carries an extra star-rating line under the name — so the reader was returning the words "POSITION" and "ARCHETYPE" as a player's position and archetype, and a surname of
A A A A A ARIAT. 4 1 OTA. off the star row. It now reads name, position, archetype and class correctly. The scouting board's screenshot import used these same coordinates and gets the fix too.
- ocrThe class table now tells a transfer apart from a signee. That column says
HS for a high-school recruit and TR (SO) for an incoming transfer, and both used to collapse to the same unusable string — so the eligibility year is kept, and which of the two lists you captured is read off the screen rather than assumed.
- bot
/matchup set now asks for the away team, then the home team — the order the game's own Scores/Schedules screen uses. Until now it took two interchangeable players and recorded no home team at all, so every user-vs-user game the commish set arrived in the web app looking like a home game on both players' schedules. There's a neutral: flag for bowls and kickoff games, where nobody is hosting. Running /matchup set again on a pair that's already scheduled moves where the game is played instead of refusing as a duplicate, so a season set before this shipped can be fixed one game at a time without clearing the week. The status board and /matchup view write games the same way — away side first, @ the host — and /matchup view counts how many games still have no home team.
- webUpcoming games can be edited from the schedule. Any scheduled game without a result yet gets an Edit link that sets Home, Away or Neutral — previously only CPU games were editable, and only to change the opponent, so a matchup set from Discord couldn't be corrected anywhere. Home/away on a user-vs-user game is stored as *which coach* is home, so either player can set it and both schedules read correctly.
- webThe matchups slate names the host under each pairing, and a game page with no box score writes the two coaches away-side-first.
- webFixed the location on a game scheduled by a web-only account (no linked Discord) saving as unknown when they picked Home.
- botFixed the Discord app closing on the scheme install. On mobile, reaching the pass-concepts page of
/scheme install offense closed the app outright — every time, on both Next and Save. That page was carrying 44 options across four menus in a single message, roughly twice the weight of any other page, because the flow decided what to fit on a page by counting menus rather than counting what was in them. It now measures the actual content, so the pass concepts get a page to themselves and no page is heavier than one that's always worked. The offensive install is six shorter pages instead of three long ones — a couple more taps on Next, and it no longer takes the app down with it. Desktop was never affected.
- webbotSay what you run in your own words. Your scheme card now takes a line of up to 280 characters — how you actually play, not which archetype you ticked. It shows above the schemes on your card, next to your name on matchup prep, and anyone who's declared their own card can read it. There's a written philosophy field underneath the offensive and defensive installs already, but that one is long, split by side, and locked behind finishing an install, so it never appeared anywhere someone would casually see it. This is the one that does. It's optional — the seven picks are still the only thing standing between you and the board. Set it on the web page, or with
/scheme card summary:"...".
- botFixed the scheme install giving up partway through. The menus stopped responding after about five minutes, which — because that's roughly how long it takes to reach the second page turn if you're reading the options rather than skimming — looked like Discord dying on a specific click. The install went from seven menus to ten when formations were added in v1.38.0, and the five-minute window never moved with it. It's now fourteen minutes, and saving late works properly instead of silently succeeding without telling you.
- dbAdded
SchemeCard.summary (text, defaults to empty). Recorded in the card's revision history, so changing it is tracked like any other declaration.
- botPreseason no longer opens a CPU Games thread. Advancing into Preseason created a CPU game for everyone without a user-vs-user matchup and tagged them all in a shared thread asking for a result — but Preseason is staff, blueprint and recruiting board, and no games get played in it. The offseason weeks were already skipped; Preseason was slipping past the same check.
- botThe Preseason status board now says what the week is for. It showed the ready list and nothing else. It now leads with the week's actions — Hire Staff & Manage Blueprint, Set Up Recruiting Board & Scout, Custom Schedules — and points to what's up next, the way the offseason weeks already did. Weeks that actually have games still lead with the games.
- webThe matchup API now rejects a game scheduled in Preseason instead of accepting it. The schedule table never offered those weeks, so nothing in the app could reach it, but the API check and the table now come from one shared rule instead of two that could drift.
- botFixed the scheme install menus ignoring everything you clicked. Picking a concept, a coverage or a formation in
/scheme install offense or /scheme install defense did nothing — the menu redrew with your old selection, so a click looked like it hadn't saved and removing a formation looked like it had been put back. Every menu in both installs was affected. /scheme card was never affected, which is why this got as far as it did.
- botClearing an install now sticks. The install pre-fills from your declared scheme when you've never filled it in — but it was checking whether the install was *empty*, so deliberately deleting all your concepts and saving brought every suggestion back the next time you opened it. It now only pre-fills if you've genuinely never saved that side, and filling it in on the web counts.
- botStopped suggesting formations your scheme would never line up in. The suggestion lists were padded out to four or five formations each, and the padding reached for an I-formation — so a Flexbone offense opened its install with I Form Tight already ticked, as did Wishbone, T Formation and Single Wing. Formations are strict now, even where that leaves three. The ones that still suggest an I-formation are the ones that actually run it.
- bot
/scheme help was still sending people to the web app for base formations, which moved into /scheme install offense in v1.38.0, and didn't mention the install arriving pre-filled or the weekly prep DM.
Scheme Cards — formations, a faster install, and prep that comes to you
- webBase formations are a picker now, not a text box. They used to be one per line, typed — which meant "Gun Trips TE", "gun trips te" and "Trips TE (gun)" were three different formations to anything trying to read them, so nothing ever did and the field became a note you wrote to yourself. Pick from ~54 sets grouped by where the quarterback stands: under center, pistol, shotgun. The bot offers the same lists on
/scheme install offense. Anything you already typed is matched up to the catalog automatically, so an existing card opens with its formations already selected.
- webYour formations now say something about you without you writing it. Pick four or five and your card, the prep view and the bot embed all carry a line like *"Shotgun, with pistol packages · One-back · Empty packages"* — worked out from what you picked. Nobody was ever going to type that sentence about themselves, and asking directly would have been two more questions on a form whose whole problem is that it's long.
- webThe install starts from the scheme you declared instead of blank. It's thirty-odd chips, and a blank one is where people stopped — but your card already says you run Air Raid, and what an Air Raid calls doesn't depend on who's asking. Start from my scheme ticks the concepts, formations, fronts and coverages that scheme usually runs, and you correct it from there, which is a much easier job than remembering from nothing. It only ever adds — nothing you'd already picked can disappear. In Discord, an install you've never touched opens pre-filled and says so.
- botYour defensive playbook is filled in from your card. The game names its defensive books after the front and the coverage —
4-2-5 Man Pressure, 3-3-5 Tite — so a finished card has already named your book. It fills in a blank only, and never overwrites one you picked.
- botThe bot DMs you what your opponent runs, every advance. Declaring your card was a one-time effort, and everything it bought sat behind a command you had to remember to run — so the coaches who'd have got the most out of prep were the least likely to go looking for it. Now anyone with a head-to-head game that week gets their opponent's card in a DM, with a link to the full side-by-side. Nothing sends when there's nothing to scout: no DM for a CPU game, and none about an opponent who hasn't declared. If you haven't declared, you get the nudge and no scheme — same rule as everywhere else, and it still follows your league's reciprocity setting.
- webBase formations appear on matchup prep, led by the alignment read — the one thing on that page you can act on before the snap.
- bot
/schedule week can now set auto or manual advance for that one week. Auto-vs-manual advance was a league-wide switch you set once on /schedule cadence — so a commish who wanted to personally run the rivalry week, or hold the clock over a holiday when nobody would be around, had to flip the whole league to manual and remember to flip it back. /schedule week now takes the same advance: choice, and it applies to just that week: auto advances at the deadline, manual pings the commish and waits for /advance, and league default clears the override. Leave the option off and the week keeps whatever mode it already had — moving a deadline no longer quietly discards a pin you set earlier. /schedule view marks the weeks that override the league setting.
- botFixed a moved deadline never announcing itself in manual mode. The "deadline reached" ping fires once per week. If a manual week had already pinged and the commish then pushed the deadline back, the new deadline came and went in silence. Rescheduling a week now re-arms the ping.
- webThe Advance automatically toggle in Settings now says it's the league *default*, so it doesn't read as a promise about a week you've pinned the other way in Discord.
- dbAdded
Week.autoAdvance (nullable) — the per-week advance-mode override. null means inherit League.autoAdvance, which is every existing week.
- web"Report a Bug" from the website now opens the feedback form. The footer on every page of dynastydesk.gg has pointed at
web.dynastydesk.gg/#feedback for the past few days, but nothing in the app was listening for that hash — so they dropped you on the dashboard with the feedback tab still closed on the right edge, which is exactly the hunt the link was meant to save you. The panel now opens on arrival. Signing in first still lands you on the dashboard with the tab closed; that round trip loses the link, and is a separate fix.
Xbox screenshots — and screenshots taken through Remote Play on a phone or tablet
- ocrXbox screenshots now import. The record book was the clear break: the shoulder-button badge that sits next to the scope pill reads
LB/RB on Xbox and L1/L2 on PlayStation, and only the PlayStation form was being stripped — so an Xbox record book resolved its conference as "LB MWC" and failed lookup entirely. Both are handled now, so nothing in the pipeline has to know which console you're on.
- ocrScreenshots taken inside Remote Play / xCloud on a phone or tablet work. These aren't console screenshots — they're screenshots of the *device*, so the game sits in a 16:9 window inside the tablet's own 4:3 screen with black bars above and below. Every coordinate was being measured against the whole file instead of the game, putting every read in the wrong place. DynastyDesk now finds the game window first and measures from there.
- ocrScreenshots below 4K are read properly instead of being rejected or misread. Crops were always sized to the image, but the sharpening applied afterwards was fixed to 4K, so a 1080p screenshot reached the text recognizer at half the size it was tuned for. That's now scaled to match. Record books read at 1080p about as well as at 4K; the dense stat tables read noticeably worse and will flag most fields for review, so 4K is still worth it where you can.
- webThe record book import no longer demands exactly 3840×2160 — or tells Xbox players to go change their PS5 settings. It now accepts any screenshot with enough of the game visible to read, and says so in words that apply to whichever console you actually own.
- ocrFixed rosters being misread when the redshirt column isn't showing. The roster table sometimes drops its leading RS column, which shifts every other column left. This was never an Xbox thing — it would have misread a PlayStation screenshot the same way; it just had never been seen. The column layout is now detected from the header instead of assumed.
- ocrStar ratings and record-book tabs no longer depend on exact screen brightness. Both were judged against fixed brightness numbers, which a HDR capture or a streaming re-encode shifts. They're now measured relative to the screenshot's own range.
- webFixed recruiting-class imports reading CF27 screenshots with CFB26 coordinates. That upload was the last one not passing your league's game version through.
- dbStat corrections now record which game version they came from. Only roster corrections did, and
/admin/ocr filters on it — so every stat correction was invisible to the accuracy dashboard it was supposed to feed. No schema change; the column already existed and simply wasn't being written.
- dbThe accuracy page can now tell "corrected 23 times" apart from "corrected 23 times out of 580". Reviewing a stat upload records the name and team reads you *accepted*, not just the ones you changed — the same thing roster imports have done since v1.28.0. Without that denominator a rate is uninterpretable: a reading that's usually right looks identical to one that's always wrong.
- webThe rule-candidate table no longer shows readings it has no denominator for. A reading with only corrections behind it scores 100% by definition, which would have sorted it above every genuine pattern. Those are held back until there's something to measure them against.
- ocrAdded a golden-file regression suite for OCR, so a coordinate or preprocessing change shows up as a reviewable diff instead of being found by a user.
npm run ocr:goldens regenerates it. See docs/ocr-capture-sources.md.
> Tip for Remote Play users: the on-screen touch controller draws over the game and can hide part of a table. It fades after a few seconds — wait for it to disappear before taking the screenshot.
Also in this release — Dynasty Cup fixes
- [web][bot] Retired coaches no longer sit on the Dynasty Cup ladder. Anyone who had left the league stayed mid-table forever, and because retiring also clears their team they showed up as a bare "…2320" row. Their old games still count for the opponents who played them — they just lose their own row, and can no longer be handed The Belt.
- [web][bot] Top recruiting class isn't awarded on a near-empty field. It's the one category decided by comparison rather than by what you earn, so in a league where one coach uploaded a class and sixteen didn't, that coach took "best class in the league" unopposed. It now needs half the ladder to have uploaded a class that season, with a floor of two.
- ciThe unit-test job generates the Prisma client before running, so a fresh checkout doesn't fail on a missing client.
The scouting board import actually reads the card now
- webFixed the Scouting Board screenshot import returning nothing. Uploads processed without an error but produced a review table with every field blank. The import was calling the Players Leaving endpoint, which reads the departures table and matches names against your own roster — a different screen answering a different question, so none of the fields it returned were ones the scouting board was looking for. It now has its own endpoint that reads the right-hand prospect card.
- ocrThe recruit card is read at the right coordinates for CF27. The template only ever had CFB26 coordinates; CF27 restyled the card, moving the OVR badge down and shifting the value rows. It now picks its regions from the league's game version, the same way the roster and records imports do.
- webA screenshot with no card showing is named instead of becoming a blank row — the warning says which file, so it's clear which one to retake rather than which row to delete.
- webA position the dropdown can't show no longer gets sent and rejected. OCR reads QB as
0B often enough to matter; that used to reach the save as-is and fail. Unrecognized reads now start empty so the cell asks to be filled in.
- webPlayers that fail to import say so. Failures were being swallowed, so importing eight and finding five on the board looked like the board losing them. Failed rows now stay on screen with the reason, ready to fix and retry.
Al has a face
- botAl now appears when DynastyDesk is the one talking — the heads-up before a deadline, the note when you message a bot-managed channel, the reconnect notice, and the autopick announcement during a draft. Previously these were a wave, a refresh arrow and a robot; now they're all the same assistant, so it's obvious when it's DynastyDesk speaking rather than another coach.
- botCPU games keep the robot. 🤖 still means "you're playing the computer this week" on the CPU games post, the schedule, and
/golive — deliberately not Al. Al is the assistant; the robot is your opponent, and one face can't mean both.
- botFixed the autopick announcement appearing two different ways depending on whether a commissioner used the draft panel or typed the command — the message existed in two places and only one of them had been kept up to date.
DynastyDesk now has its own emoji
Two things DynastyDesk talks about constantly have no emoji in Unicode: a coach's headset and a championship belt. We'd been substituting — 🎧, which Discord draws as headphones with music notes, and 👑 for a belt. Both are now custom artwork.
- botCommissioners are marked with a coach's headset — actual DynastyDesk art, not the stock headphones. Shows up in
/roster list, /dd commish list, /dd setup, and /help.
- botThe Belt has a belt. A gold championship plate, drawn for it. It replaces the crown everywhere the belt appears — the status board, the Cup standings,
/cup belt, the Hall of Fame, and the announcement when it changes hands.
- webThe same belt shows on the web, on the Dynasty Cup page, the belt history, and next to the current holder — so it's one object across both surfaces instead of a different symbol in each.
- webIt's the first option in the belt icon picker in Settings, ahead of the ten emoji presets. Leagues that prefer 🐐 or ⛓️ keep them; nothing you've already chosen changes.
- db
League.beltEmoji now defaults to the DynastyDesk belt. The column stores either a Unicode emoji or the sentinel dd:belt, which each surface renders its own way — a Discord custom emoji can't display on the web, so the value stays neutral and the surfaces resolve it.
- botNew
npm run bot:emojis uploads the artwork to Discord as application emoji, so they work in every server the bot is in without a per-server upload.
Icons are held to a different standard than the rest of the artwork — they're judged at 22 pixels, not full size — and the rules now live in brand/standards/visual-direction.md.
The team draft, without the command list
The draft shipped with 21 slash commands. Typing /draft showed thirteen entries with pick at the top — the last thing a commissioner ever does — and the actual starting point eleventh. Everything worked; nobody could tell where to begin.
- botThe board is now a control panel. During setup it reads as a four-step checklist — coaches, schools, odds & clock, lock it in — with a tick or a warning against each and the exact thing that's blocking you. Every step is a button. A commissioner can run a draft from start to finish without typing a single command.
- botThe buttons change with the draft. Only what makes sense right now is on screen: Lock it in stays greyed out until you have enough schools for the coaches you've added, Draw the order replaces it once locked, then Open picking, then the running controls. Nothing offers you something the draft would reject.
- botPicking coaches and schools is now a picker, not a name you have to spell. Choose coaches from a member list; add whole conferences at once; or narrow to a conference and tick individual schools. Conference filters follow your league's own realignment.
- bot
/dd setup asks whether you need one. New leagues get a yes/no at the end of setup rather than a fifth line in a list — the leagues that need a draft need it before anyone has a team, which is right then.
- botTurning Team Draft on from the web posts the panel to Discord, so there's nothing to go and find.
- botFixed
/help, which was telling commissioners to run /draft pick — a command that does nothing until a draft exists and they're in it. It now points at the one command that starts everything.
- db
League.draftPromptedAt, so the "here's how to start" panel posts once rather than every fifteen minutes.
The slash commands all still work, for anyone who prefers typing or finds buttons fiddly on mobile.
The team draft, with proof
Leagues have been running their draft on a Twitch stream with a third-party spinner and a spreadsheet, then typing the results back in by hand. The reason for the stream is transparency — the only thing making the order believable is that everybody watched it happen. That's a weaker guarantee than it looks: it proves the wheel spun, not that the wheel was fair.
- [bot,web]
/draft runs the whole thing — build the pool of schools, draw the order, and pick asynchronously until everyone has a team. The result writes straight through to your roster, so the draft *is* the league setup. No export, no re-entry.
- [bot,web] The lottery is checkable afterwards, by anyone. Before the draw, the bot publishes a hash of the secret seed to your channel — Discord's own timestamp proves when. The commish then adds public randomness (a word from chat, a scoreline), and the order follows from it deterministically. Afterwards the seed is revealed, and
/draft verify re-runs the draw and confirms it produces exactly the published order. If you'd rather not take our word for it, docs/team-draft.md has a dependency-free script that re-runs it in your own terminal.
- webA stream page at
/live/<token> — public, read-only, safe to put on an overlay. Ping-pong balls tumble in a hopper and drop into the order one at a time, paced off the server clock so the streamer, someone arriving forty seconds late, and someone refreshing all see the same ball land at the same moment. A weighted lottery is literally visible: a coach with ten balls has ten in the drum. The verification runs in the viewer's browser, not on our server — a green tick computed by the same machine that ran the draw wouldn't prove anything.
- botMissing your pick window costs your place in line, not your pick. NFL rules: when the clock runs out you're *passed*. The next coach goes up immediately so the draft never stalls on one person, and you can still claim your school whenever you show up — the moment you do, it's yours. There is no deadline you can miss that takes anything away from you.
- [bot,web] Equal odds or weighted. Weighted hands out ping-pong balls per coach, so a league can give last season's bottom finisher better odds at the first pick. Both modes run through the same draw, so neither is the less-tested one.
- webA proper pool builder. Filter by conference, then add and remove individual schools from a searchable grid of all ~140, with team colours and logos and a counter that turns red when there are fewer schools than coaches. Conference filters respect *your* league's realignment, not the stock alignment. Curating this through Discord autocomplete was miserable; the slash commands are still there as the phone fallback.
- botEvery message tells you what you can do next. The available commands are derived from the draft's state and your role rather than hand-written per message, so the board can never offer you a command it would then reject.
- bot
/dd newdynasty now carries your feature settings into the new era. The Dynasty Cup, scheme cards and the team draft were all being silently switched off at rollover, with no indication which switches to go re-flip. It also points you at /draft setup start, since a new era clears everyone's team anyway.
- db
TeamDraft, DraftParticipant, DraftPick, plus draftEnabled / draftChannelId on League.
The add buttons follow you up the page
The buttons that put players *on* a recruiting board sat below the table. Once the board holds a full recruiting class or a nine-man exodus, that's off the bottom of the screen — adding the next player meant scrolling past everything you'd already added.
- webAdd from roster, Add someone not on your roster and Scan a screenshot now sit above the transfer portal table as well as below it. Same three actions with the same behaviour, so "Add from roster" still appears only on My Players. An empty board keeps its single inline prompt instead of showing the row twice.
- webThe scouting board and the high school board got the same treatment — Add to scouting board / Import from screenshot, and Add recruit, are now reachable without scrolling to the end of a long table.
Reading the leaving screen is a match, not an import
Shipped in v1.32.0 as an import form — a grid of editable names, positions, overalls and dev traits. That was the wrong shape. Everyone on that screen is already on your roster, so there is nothing to type: the screenshot only needs to say *who* is leaving and *why*.
- webThe review step now shows matched players, not form fields. Each row reads as the roster player it resolved to —
T.Earl · TE · JR (RS) · 73 OVR — with the reason underneath. The editable name, position, dev trait and overall boxes are gone; those values come from your roster, which is a better source than a 40-pixel crop.
- webRows we weren't sure about now ask you, instead of guessing. A confident match is ticked and marked. A repaired misread (
0. Herndon → O.Herndon) is held back with a picker pre-set to our guess and the text the screenshot actually read, so you confirm rather than find out later. A row that matched nobody is highlighted with a picker of closest guesses and your full roster — and can't be ticked until you pick someone, so an entry can never be added pointing at no one.
- webDropped the status column from the review step. Every row was showing an "In Portal" dropdown, including players who aren't going anywhere — a graduating senior or someone you persuaded to stay read as "In Portal" while greyed out, which looked like it was about to do something alarming. Those rows now simply say why they're excluded. Anything you do add is In Portal by definition.
- webThe action is called Scan a screenshot rather than "Import from screenshot", because nothing is being imported.
The transfer portal, for people who actually use it
Filing your departing players took nine screenshots and nine uploads, or a lot of typing. It now takes one screenshot — or no screenshot at all.
- ocrOne screenshot of the Players Leaving table now reads every row on it. The parser was pointed at the player card on the right-hand side of the screen, which shows one highlighted player, so a nine-man exodus meant clicking each player, screenshotting, and uploading nine files. It now reads the table in the middle: name, position, class, overall and the reason they're leaving, for everyone visible. Scroll and add a second shot if your list runs longer than the screen; overlapping rows are recognised as the same player rather than added twice.
- webThe import was calling the wrong parser entirely. "Import from screenshot" ran the *recruiting card* reader, which also meant it never received your league's game version and read CF27 screenshots with CFB26 coordinates. That's why the results were thin.
- webGraduating seniors and anyone you persuaded to stay are left out. The reason column is read and understood, so only genuine transfers are ticked. Players who are graduating, who declared for the draft, or whose line reads "Staying:" after a successful persuasion are listed but unticked, with the reason shown — visible, so you can tell a misread from a real exclusion, but never imported by accident. If you persuaded someone you'd already imported, the row offers to remove the now-stale entry.
- webDeparting players are picked off your roster instead of retyped. "My Players → Add" used to open a blank create-a-player form, which is odd given the player is already on your roster. It now opens your roster — non-Seniors only, alphabetical, searchable, multi-select — and carries the position, overall, archetype and dev trait across. Anyone already on the portal board is shown greyed out. Adding someone to the portal does not remove them from your roster; nothing disappears until you say so.
- webImported players are matched to your roster. The table only shows an initial ("G. Stork") and no archetype or dev trait, so each row is matched against your actual roster to recover the full name and the rest of the details — using the same name-matching that learns from corrections you've already made elsewhere. A name that doesn't match still imports; it's just flagged for you to finish.
- webIncoming and My Players no longer look identical. Each tab has its own colour and icon, carried onto the table header and the panel edge, plus a line saying what you're looking at. The two tabs hold opposite things and rendered near-identical tables.
- webThe reason a player left is kept, shown as a column on My Players, and editable.
- db
PortalEntry.reason and PortalEntry.playerId. The second one links a portal entry back to the roster player it came from, which is what makes the picker's duplicate detection reliable and closes the portal half of a known gap in the data model.
- webAdding several players at once is a single request instead of one per player, so a nine-row import can no longer half-succeed.
Set your advance schedule from the web, not just Discord
The cadence was the one big league setting you could only change with a slash command. It's now on the web app's Settings page as well, and the two are genuinely the same setting rather than two copies that drift.
- webSettings → Advance Schedule. Set how often the league advances, when the next deadline lands, and whether it advances automatically or waits for a commish. Shows the next advance in your own timezone, with Daily / 2-day / 3-day / Weekly shortcuts. Commish only, same as in Discord. Changing just the interval keeps your existing start time, so the schedule holds its phase instead of quietly re-anchoring to right now.
- botThe Discord status board now reconciles itself. Every 15 minutes the bot compares the posted board against the league's actual state and rewrites it if they've drifted — which is how a schedule you set on the web reaches Discord. When it's the deadline that moved, it also posts a note in the league channel, because editing a message in place is invisible to anyone who isn't looking at it.
- bot
/schedule cadence refreshes the status board. It changed the deadline everywhere except the board itself, so the board kept advertising the old time until the next advance overwrote it. /schedule week was fixed in v1.29.5; this was the same bug in its neighbour.
- botThe date and cadence math moved into a shared package used by both the bot and the web app, so a deadline computed on one surface is the same instant on the other. No behaviour change on its own — it's what makes the above safe.
Fixes
- botRetired coaches no longer get pinged about the advance. The 24-hour and 6-hour reminders pulled everyone who hadn't readied up without checking whether they were still on the roster — and retiring someone sets them to "not ready" — so anyone who left the league was tagged before every single advance, forever. The reminder now uses the same active-roster filter as the status board, and skips web-only members who have no Discord account to mention.
- botRetired coaches no longer get a CPU game every week. Each advance auto-created a sim matchup for every member without a head-to-head game and tagged them all in the CPU Games thread, retired or not.
- bot
/preview counts the active roster only. It included retired members in the ready and not-ready lists, so it reported the league wasn't safe to advance because of people who had already left.
- botThe "did you play your game?" DM that goes out an hour after a scheduled kickoff now skips retired members, so any leftover CPU games from before this release stop reaching them.
- botA coach who accepted a new job and then retired before the End of Season Recap is no longer moved to that school and announced as having taken it.
Every command asks for a date the same way
The last few commands that wanted a hand-typed date now use the same picker as the rest — day, hour, am/pm, with minute, month and timezone optional. All of them read the nearest sensible future date, so picking the 3rd when it's already the 28th means next month, and a date that doesn't exist is refused instead of quietly landing somewhere else.
- bot
/away set takes a day instead of MM/DD. It also had the worst of the date bugs: your return date was read on the bot's clock rather than yours, there was no timezone to pick, a typo stored a broken date without complaining, and the year was hardcoded to the current one — so anyone away over New Year came back in a January that had already happened. The date it stores now reads as the same day for everyone, wherever they are.
- bot
/week set takes the picker rather than a typed 2025-09-15T22:00:00. Nothing else about the command changed.
- bot
/matchup schedule no longer schedules games in the past. It took the month and year from the bot's clock and never looked forward, so choosing "the 3rd" late in the month set your game three weeks *behind*. Minute is optional now too, defaulting to :00, like everywhere else.
- bot
/schedule cadence reads your calendar, not the server's. It took the current month and year from the bot's clock, which is an hour or more ahead of most of the US and can be a whole year off on New Year's Eve. It also accepted dates like February 31st and silently moved them into March, quietly shifting every deadline the cadence projected afterwards. An anchor is still allowed to sit in the past — that's how a fixed schedule holds its place.
Fixes
- bot
/schedule week no longer asks which number "Players Leaving" is. The week was a bare required number with no autocomplete, so moving a deadline meant knowing the schedule by heart. It's optional now and defaults to the week you're on — leave it alone to change the deadline in front of you, or start typing a week's name and pick it from the list.
- botIt takes the date the same way
/advance start does — day, hour, am/pm, with minute, month and timezone there if you need them — instead of a hand-typed 2025-09-05T22:00:00.
- botMoving the current week's deadline now updates everything that shows it. It only ever wrote the week's own record, so the status board and
/mydesk kept showing the old time — or nothing at all — until the next advance overwrote them. It now refreshes the board and tells any open game thread that the window moved. Ready check-ins are kept: moving a deadline isn't a reason to make everyone mark themselves ready again.
Fixes
- botA manual advance deadline now survives the advance.
/advance start showed the time you set, and then /advance confirm threw it away — the advance recalculated the deadline from the league's cadence and overwrote your answer, which on a league with no cadence set meant no deadline at all. The post said nothing, the week had no deadline, and the matchup threads didn't mention one either. Your time now wins over the cadence and carries through all of it.
- bot
/advance start stopped asking for an ISO timestamp. It wanted 2025-09-05T21:00:00 typed by hand, and read it in the server's timezone rather than yours — so an entry that looked right landed hours off. It's now the same day / hour / am-pm picker /schedule cadence uses, with optional month, minute and timezone (default Central). It reads the nearest sensible future time: the 3rd when it's already the 10th means next month, the 31st in a 30-day month moves to the next month that has one, and a date that never exists is refused instead of silently landing somewhere else.
- botA deadline set on
/advance start also applies when it's kicking off Week 1, and the preview now shows the anchored cadence time it will really use instead of a rough "now plus the cadence".
Fixes
- webOne Save button for the whole scheme page. There were three — one per section — so filling the page in and pressing the nearest one saved a third of it, with no error to explain why the rest vanished. It's now a single save bar that persists everything you changed and tells you when you have unsaved work.
- webPicking several concepts in a row no longer loses all but the last. The chips read the list as it was at the last render rather than as it was after your previous click, so anything selected in quick succession overwrote itself. Click as fast as you like now.
- webDropped the stream link from the scheme install. Your Twitch and YouTube links already live on your roster entry and are set with
/roster set — collecting them a second time just created two copies that could disagree.
- dbRemoved
SchemeCard.streamUrl.
Fixes
- dbA missing
DATABASE_URL now says so. The schema-change procedure temporarily renames .env out of the way, and if that rename is never undone the next command touching the database — bot:deploy, bot:dev, any script — died in ten lines of minified Prisma stack that named neither the variable nor the reason. It now names the variable, spots the leftover .env.bak, and gives the one-line fix.
Fixes
- botThe invite link now asks for permission to create channels.
/dd setup offers to build #dynasty-desk for you, and /dd streams channel and /dd scheme channel do the same for theirs — but the published invite link never requested Manage Channels, so on a fresh server every one of them failed with "Couldn't create a channel" and left you to make it by hand. It requests it now. Servers that already added the bot need to re-invite it with the new link, or tick Manage Channels on the DynastyDesk role — Discord doesn't hand new permissions to a bot that's already in the server.
Scheme Cards
Behind a feature flag and off everywhere. Commissioners opt in from League Settings once it's switched on.
- webEvery coach declares what they run. Seven answers — offensive scheme, variant, tempo, run/pass lean, defensive scheme, coverage identity, base shell — and you're on the board. About two minutes. There's a deeper install underneath it for playbook, concepts, fronts, coverages, base formations and written philosophy, saved one side of the ball at a time.
- webNobody reads a scheme until they've declared one. Your card unlocks everyone's cards; your offensive install unlocks everyone's offensive installs, and the same for defense — they unlock separately, so finishing one side is worth doing on its own. Commissioners always see everything. Commissioners can turn the whole rule off for leagues that would rather be open.
- webMatchup prep shows how these schemes have actually fared. Open an upcoming game and you get your card next to theirs, plus every prior meeting of those two schemes across the league's whole history — not just games between you two. It reads what each coach had declared *at the time* of each game, so a coach who switched schemes in week 7 doesn't have their old games counted under the new one. Their last four games sit below it with the run/pass split the box scores actually show, next to the lean they declared. Both numbers, no accusation.
- webA filterable league board at
/scheme/board — who runs Air Raid, who's on a 3-3-5, who plays warp speed.
- bot
/scheme card steps through the seven picks with menus that remember what you already chose, /scheme install offense and /scheme install defense add the depth, /scheme view reads a card, and /scheme prep pulls up your next opponent. Playbooks autocomplete — start typing a school or a scheme and every book in the game is there.
- botA board in its own channel, alphabetical by school.
/dd scheme channel creates #scheme-cards and the board keeps itself up to date in place. A ▲ marks anyone who's changed scheme since the week began. Buttons at the bottom let a new member set theirs or read someone else's without knowing a command exists. When somebody still hasn't filled theirs in, the bot says so once per advance — and stays quiet when everyone's done.
- botDeclaring a custom playbook obliges you to describe it. Nobody can look up a book you built yourself, so your install becomes the only description of it anyone gets, and the card says so.
- dbNew
SchemeCard and SchemeCardRevision models, plus four League columns. Every change to a card is kept with the week it was made, which is what lets the prep view answer "what were they running in week 4" instead of only "what are they running now".
- webWhere scheme cards are on, the free-text scheme fields on Season History become a read-only view of your card rather than a second answer that can drift away from it.
- dbThe feature-flag check has one implementation now, shared by the web app, the bot and the packages. It had three.
Fixes
- ciThe test suite passes again. A unit test added in v1.25.0 imported a module that reaches for the database as it loads, and CI's fast suite deliberately runs without one — so every push since has reported a failure while the release itself went out fine. The check that decides who an import writes to now lives in its own dependency-free file, which is where it always belonged.
- socialEvergreen posts stopped losing their link. Long posts were shortened by cutting the end off the finished post — and the end is where the link and hashtags are. Every queued post was going out mid-sentence with no way to click through and no tags. Only the body gets shortened now; the link and hashtags always survive.
- socialPosts are built a little under X's limit rather than exactly on it. Sitting on the boundary was enough for the scheduler to reject the whole post and fail the run.
The OCR learns from your roster corrections now
- webFixing a misread roster row teaches DynastyDesk something. Every rule the reader knows — that 7 comes through as 1, that QB reads as 0B, that MIKE reads as MIRE — exists because a person noticed a pattern and someone wrote it down. Meanwhile the review table was holding the answer at the moment of every fix: what the reader saw, right next to what you corrected it to. It threw the first half away on import. It keeps it now. Nothing extra to do — the corrections you were already making are the whole input.
- dbWhat you *didn't* change is recorded too, and that's the point. A read that was corrected 23 times sounds damning until you learn it was seen 24 times or 580. The first is a character the font renders badly, and a rule fixes it for everyone. The second is a crop landing in the wrong place, where the same reading is often correct and a rule would make things worse. Without the reads people accepted, those two look identical — which is why the accuracy page could never tell them apart before.
- webThe admin OCR page gained a rule-candidate table ranking each reading by how often it gets corrected against how often it was seen, and its existing statistics now count only genuine corrections.
- webReconstructed OVRs — the italic ones guessed from roster order — are left out. There's no reading behind them, so counting one would be recording a misread of nothing at all.
Retiring someone who already left Discord
- bot
/roster retire now picks from your league roster, not from Discord. The command took a Discord user, which meant the one member you most often need to retire — the one who has already left the server — was the one it couldn't offer you. Both retire and unretire now autocomplete against the league's own member list, so someone leaving the server no longer strands them on your roster. Web-only members with no Discord account at all are reachable for the first time.
- webThe League page gained a commissioner's member list, with retire and unretire beside each name and retired members kept visible below. Retiring is still only a flag — players, results and records are untouched, and it can be undone.
- webA league can't retire its last active commissioner, which would leave nobody able to administer it from either surface.
- webThe League page recognises a Discord-side commissioner again. It was reading only the member row's flag, so a commish listed on the league but never flagged on their member row saw no commish controls there.
The NFL draft moved off the roster
- webThe NFL Draft column is gone from the roster table. It put a dropdown on all ~85 rows to serve the handful of players who leave once a season. Recording a pick now lives on the NFL Draft Board, where the draft class is the subject rather than a column — choose a player, choose a round. Rounds already recorded are editable there too, including clearing one set by mistake. The "→ NFL" badge still marks a departing player on the roster.
- webThe draft page says plainly that Portal Week 1 is when to do this, which is the week the schedule already labels "Draft Results".
Fixes
- webNormal and Impact dev traits are finally distinguishable. Normal had no colour of its own and Impact was light grey, so on a dark background the two most common traits looked identical. The four traits now run across four separate hues, in both light and dark.
Fixes
- webThe OVR column is readable again. The review table's OVR cell was a browser number field, so every row drew a pair of native stepper arrows directly on top of the rating — a two-digit OVR sat behind them, and an empty one showed nothing but arrows. It now uses the same plain numeric cell as the roster table. That also removes two ways to change a rating without meaning to: Up/Down arrow keys and the scroll wheel both step a number field, so tabbing through the table or scrolling over it could quietly re-rate a player.
Fixing a roster after import, without fighting the page
- webA flagged row no longer vanishes the moment you start fixing it. With *Show only flagged* on, the list was filtered on live flags — so the first character you typed cleared the flag, the row dropped out from under the cursor, and the correction had to be retyped from the unfiltered list. The visible set is now frozen when you switch the filter on; rows stay put while you work through them, and the remaining count still ticks down as you go. Untick and re-tick to pull a fresh set.
- webEverything is in surname order now — the review table, and the roster itself after import. The roster used to group by position with incomplete players lifted to the top, which meant finding a specific player required knowing what position the OCR had guessed for him, and checking that guess is the whole reason you're there. Incomplete rows are still highlighted, just no longer moved.
- webA "?" by the player count lists what the OCR is known to get wrong — that 7 reads as 1, that a leading initial can come through as a digit, which position and class misreads are auto-corrected and which aren't. It's the same list the parser works from, so it stays honest.
- webLosing a reviewed roster now takes a confirmation. Closing the modal, clicking away, or hitting Back on a table you've spent ten minutes correcting used to discard it silently, and the only way back was to upload and re-read every screenshot. All three ask first. Back was the gap worth closing: it fires no browser warning of its own, so it was the one people hit by accident.
- webA Save button sits under the roster table as well as above it. On a full roster the header button is a long scroll away from the row you just edited.
Positions match the game you're playing
- webCFB27 leagues stop offering positions CFB27 doesn't have. RB, T, G, LE, RE, MLB, LOLB and ROLB are gone from the dropdowns; HB, LEDG/REDG and MIKE/WILL/SAM are what that game uses. A player already stored as one of the old positions still shows his own value, so nothing gets silently rewritten on the next save.
- webImports into a CFB27 league keep linemen side-specific. The importer was collapsing LT/RT into T and LG/RG into G, which threw away real information and left the reviewer looking at a position the dropdown no longer offered.
- webThe league switcher says CFB27, not CF27.
Commissioners can enter a roster for someone else
- webPick whose team an import lands on. Every league has a member or two who won't upload their own screenshots, and commissioners were already taking the pictures for them — but the import always wrote to whoever was signed in, so doing it on their behalf piled every one of those players onto the commish's own roster. Nothing collapsed, either: the importer matches on name *and* position, so ten rosters became one team with several hundred players and no way to undo it in bulk. Commissioners now get an Import to picker on both the screenshot upload and the spreadsheet wizard. Everyone else sees exactly what they saw before.
- webIt says whose roster you're filling, the whole way through. Choosing someone else puts their name on the review step, the mapping step, and the confirmation, because the mistake this prevents is a long careful import into the wrong team.
- webThe spreadsheet wizard's duplicate check now looks at the roster you're importing *to*. It previously compared against your own players, which meant a commish importing for someone else was warned about their own roster and told nothing about real duplicates on the target's.
- webA roster import can no longer touch an archived dynasty. The database guard only covers new rows, and this path also updates existing players, so a past era was editable through it.
Fixes
- webThe "merge your solo team" banner no longer follows you into your Discord league. If you tracked a team solo and then joined a league, the prompt appeared on every page of every league you were in — including the league itself, where it read as though it were about to fold your solo team into the dynasty you were looking at. It wasn't; a merge never starts without picking both sides and confirming a preview. But it was alarming, it was on a page where it made no sense, and there was no way to dismiss it. The prompt now appears only while you're actually viewing the solo team it's offering to merge, which is what the sidebar's quieter version of the same link already did.
The crown now means one thing
- botCommissioners are marked with 🎧 instead of 👑. The crown was doing two jobs at once — it marked whoever ran the league, and it's the default emoji for the belt — so the same symbol meant both "this person handles the advances" and "this person is on top right now." Those are unrelated, and only one of them is an achievement. Commissioners now get a headset, which is the job: the person in the booth running the show. The crown is left to the belt, where winning it actually means something. Shows up in
/roster list, /dd commish list, /dd setup, and /dd help.
Recaps now follow a record chase
- botStorylines can see who's closing in on a record. A player on pace to break a school, conference, or national record is the best story a season produces — it has someone to root for, a number to beat, and it builds week over week — and the recap was the one place that couldn't see it. Your dashboard would show a receiver at 94% of a school record while that week's write-up talked about something else entirely. Chases now go into the recap alongside the games, with the record, who holds it, and what the pace projects to.
- botIt can talk about a chase without getting ahead of it. A recap can say someone is on pace, or how much they need — it can't say they're going to break it, and it can't tell you a record fell until it actually has.
- webThe dashboard's Record Watch and the recap now work from one shared calculation instead of two, so they can't disagree about who's chasing what or how close they are.
Telling us a recap was bad now tells us what to fix
- botReact 👎 to a weekly recap and DynastyDesk asks you what missed. A thumbs-down said a recap was bad but not *why*, and the things that go wrong have completely different causes — a wrong score is a very different problem from a recap that skipped the game everyone was talking about. You now get a direct message with four one-tap answers, plus a box if you'd rather write it out. One tap, once per recap, and only if you were the one who thumbed it down.
- webThose answers collect on a new admin page, grouped by what each one actually points at, with anything written out longhand pulled to the top to be read rather than counted.
- dbYour written feedback is kept even if you later take the 👎 back — the reaction is a mood, what you typed is information.
Every advance is now recoverable
- dbThe league is photographed before every advance. Advancing a week rewrites a lot at once — player classes, staff seats, recruiting boards, matchups — and it did all of it with no way back. If something went wrong halfway through, the league was stuck between two weeks with no record of how it had looked a moment earlier, and simply running it again would have aged every player a second time. Each advance now stores exactly what it is about to change, first, so a bad one can be examined and undone by hand instead of guessed at. Nobody has lost a season to this; it is the kind of thing you want in place *before* somebody does.
- botwebAn advance that cannot be recorded does not happen. Refusing to advance is a visible problem you can retry in ten seconds; advancing unprotected is an invisible one you find out about a season later. Both the Discord
/advance and the solo dashboard button behave the same way, and the web button now says plainly that nothing was changed.
- botOne league failing to advance no longer holds up the others. Automatic advances are processed independently, so a single stuck dynasty can't strand every league queued behind it.
Fixes
- ciThe season-rollover guard added last release now also covers this new record, and the reason it is exempt is written down rather than assumed.
Fixes
- webSeason history and awards now survive a solo→league merge. Every other collection followed you across; these two stayed behind in the abandoned solo dynasty, so a player who finished a season and then joined a Discord league lost the record of that year. Nobody has hit it — no solo dynasty has completed a season yet — but it would have been silent and unrecoverable when they did. Where you and the league have both recorded the same year, the league's copy is kept.
- ciAdded a guard that fails the build when a new database model is added without deciding what happens to its rows at season rollover and on a solo→league merge. Both bugs above came from that question never being asked; it is now asked automatically. It found the two above on its first run.
One place to record a coach
- webSeason History no longer asks you to type your coaches' names. It reads them from the Coaching Staff page instead, so one coach is one person with one career and one skill history — typing a name in a second place only ever created an unlinked duplicate. A season with nobody recorded now points you at the Coaching Staff page rather than inviting the copy. Offensive and defensive schemes stay where they were; they describe the program, not a person.
- webAnything you typed before the Coaching Staff page existed is kept and used to pre-fill the name fields when you set that coach up, so nothing has to be retyped.
Why stacking matters
- webThe staff bonus panel now explains the mechanic it's built around: your coordinators' archetype bonuses pay XP to your head coach as well as to themselves. You can't direct an assistant's XP or spend it, so the coordinators are really there to feed your own coach — which is exactly why matching a coordinator's archetype to your head coach's is worth doing. The coordinator tabs say the same thing where you pick the archetype.
Fixes — your staff survives the offseason
- webbotYour coaching staff no longer disappears when the season rolls over. A staff seat is recorded per season, and nothing was carrying them into the new year — so the first advance past Encourage Transfers left all three seats empty, as though the staff had walked out. Anyone without a departure recorded now keeps their seat, and their skill tree carries over as the new season's starting point, which is exactly what the year-over-year comparison needs to measure from. Coaches you fired or who were hired away are correctly left behind, and where a coordinator was originally hired from isn't repeated on every subsequent season.
- webFilling an empty seat no longer risks splitting a coach in two. The only way to fill a seat was "add a coach", which created a new person — so a coordinator you brought back would exist twice, with their career history and skill growth divided between them. You can now pick anyone who has held a seat on your staff before, and they keep one history.
- webCoach Point totals now account for staff discounts. CEO's *Bundle Discount* and Program Builder's *Friends & Family Discount* both cut what coaching costs based on how many of your coaches share an ability or archetype, and the figures on the skill tree were ignoring them. The saving is shown alongside the total.
Your staff, on the dashboard, in the week it matters
Knowing a bonus exists is close to useless. Knowing it fires *this week* is the point of tracking a staff at all — so it now comes to you.
- webA coaching staff card on your dashboard, keyed to the week you're actually in. At Training Results it leads with your skill-cap bonuses; during portal weeks, your portal skills; in the bowl weeks, the coordinator-hiring ones. Same numbers as the Coaching Staff page — stacked across your whole staff, consolidated across positions.
- webIt stays quiet when it has nothing to say. Most weeks, a staff has no decision attached to it, and a card that's always there but usually empty just teaches you to skim past it. Gameday and passive XP never trigger it — they're real, but they aren't decisions.
- webIt asks for what it needs, when the game changed something. Preseason: record the staff, because coordinators upgraded themselves over the offseason. Conference championship week: record who you fired. Bowl weeks: record who you hired and where they came from. End of Season Recap: Coaching Points were just awarded, so update your head coach's tree.
- webIf you haven't set a staff up yet, the nudge only appears in weeks where something actually changed in game — not every week forever.
Coaching Staff — bonuses say what they do
- webThe staff bonus panel now leads with the effect rather than the ability name. *PUT IN WORK* reads as Offseason Training Boost, *LIMITLESS* as Random Skill Cap Increase On Level Up, *GIFT OF GAB* as +3 Persuade Attempts. The in-game name is still there on hover, so you can match a line back to the tree you're looking at. Every ability in the game has a written-out effect; the two ratings-boost trees use the game's own wording, which already describes the effect rather than naming it.
Coaching Staff — a bonus list you can actually read
The staff bonus panel was correct and unreadable. Buying out a Tactician tree produced sixty-odd lines that differed only by position, which buried the handful of things worth knowing.
- webThe same ability across several positions is now one line. Instead of eight rows of *Star Maker*, you get Star Maker — QB, RB, WR, OL. Where two coaches both supply it for the same position, that position is highlighted with a ×2, because that's the one that genuinely pays twice. A full staff that produced 62 lines now produces 18.
- webWhat's live this week floats to the top, labelled with the week. During Training Results, your Architect skill-cap bonuses lead the panel.
- webGameday and passive XP are collapsed into a drawer at the bottom. They're real, but nothing is asked of you when they fire and there are a great many of them — they were crowding out the decisions.
- webEverything else is ordered by where it falls in a dynasty year rather than by how many entries it has.
Filling in a tree
- webPositions are click-to-count now, mirroring the in-game menu: tap to go 0/4 → 1/4 → 4/4, once more to wrap back to 0/4. Ability names and descriptions are gone from the editor — you're transcribing a tree you're looking at in game, where the position and the count are what you read off the screen.
- webCoach Points and Save now sit in a bar above and below the tree, tinted while you have unsaved work, rather than a single strip at the bottom that blended into the page.
- webUnsaved skill edits survive switching tabs. Previously, half-filling in your OC and glancing at your DC discarded the lot. Tabs with unsaved work now carry a dot.
- webWhere a coach came from has moved out of the header into a Career & history section, together with their season-by-season skill growth.
- webDeparture is behind a "This coach left the staff" button rather than sitting open on every coach you hire, and the free-text school boxes are gone from both hiring and departure.
Fixes
- webNothing is locked — carried over from v1.18.1 and now reflected everywhere: any archetype can be primary regardless of what you've spent.
- dbCorrected the skill data for Motivator and Recruiter. Three of Motivator's eight positions and several of Recruiter's carried the *advanced* tree's ability names (Master Motivator's and Elite Recruiter's), which is a copy/paste error in the upstream source — every other tree names a tier identically across all eight positions. It mattered: ability names decide which week a bonus is reported under, so those positions were being filed in the wrong section.
Coaching Staff — faster to fill in, and it stops arguing with you
- webNothing is locked any more. The skill trees used to enforce the game's own unlock requirements — you couldn't set CEO as your primary without the spending to justify it, and the advanced archetypes wanted coach level 10. That was wrong for what this page is: you're recording a staff that already exists, and the game hands out coaches whose trees don't follow those rules. Every archetype can now be set as primary, every tree can be filled in, in any order. The requirements are still listed under each archetype for reference, but nothing is blocked.
- webHead coach, OC and DC are now tabs on one page instead of three cards, each tab showing who's in the seat. The full staff bonuses panel sits alongside the editor and stays put as you scroll, so you can watch the staff total move as you set skills.
- webEvery archetype is expanded by default and collapsible, so filling in a tree is scrolling and picking rather than opening one accordion at a time. Branches with anything set are tinted, each dropdown names the ability at that tier, and a sticky save bar tracks unsaved changes.
- webArchetypes are ordered the way you meet them — Motivator, Recruiter and Tactician first, then the advanced tier built on each, then the hybrids, leadership, and the paid trees last.
- webPositions now match the in-game grid — QB, RB, WR, OL across the top and DL, LB, DB, K/P underneath, instead of alphabetical order that split the offense and defense apart. Scheme Guru gets the same offense-over-defense split, and CEO lays out 3×3.
- webArchetype and its bonus are now the headline on a coach, with Coach Points demoted to a running total on the save bar and coach level moved into the coach's details.
Fixes
- webCoach level silently did nothing if you set it before recording any skills — there was no season record yet to attach it to, and the value was dropped without a word. It now saves either way, including for a coach created moments earlier.
Coaching Staff — what your staff is actually giving you
Your head coach and coordinators are worth real bonuses, and the game gives you no way to see them together. Coordinators are the worse half: they upgrade themselves during a season and there is no screen anywhere that tells you what they picked up. This is a home for all of it.
- webNew: Coaching Staff. Record your head coach and both coordinators, set each one's archetype and unlocked skills, and see the whole staff's bonuses in one place. Off by default — a commissioner switches it on in League Settings.
- webBonuses add up across your staff, and the page adds them for you. If your head coach and your OC both own *Limitless — QB*, you get it twice, and it says so with both coaches named. If both are Architects, *Max Gains* reads 800 XP, not "400 XP, twice". This is the whole point: you shouldn't have to do the arithmetic in your head to know what you're playing with.
- webGrouped by when it matters, not by who has it. An Architect's skill-cap bonuses only pay off during Training Results, so that's how the list is organised — recruiting, gameday, portal, training results, the draft. Everything is tagged to the week it fires.
- webEntering a tree takes about a minute. Skills unlock in order down each branch, so instead of thirty-two checkboxes you set how far down each branch a coach has gone — eight dropdowns for a full tree. Locked archetypes show their requirements with live progress against what you've actually spent, so "Spend 200 in Tactician" tells you where you are.
- webOnly your primary archetype grants its bonus. You can have several trees open, and every skill you've bought in any of them counts — but the headline archetype bonus comes from the one you've set as primary. The staff view reflects that, so switching your primary moves the bonus rather than adding a second one.
- webA hiring history that survives the carousel. Record where a coordinator came from (school and role), and when they leave, why — fired, hired away, retired, or off to the NFL — and where they went. Staff History keeps the whole run, season by season.
- webSee what your coordinator gained. Record their tree at the start of each season and the next year's entry shows exactly which skills appeared in between. This needs two recorded seasons before it can show anything, and says so rather than looking broken.
- webRainmaker and Visionary can be switched off per league. Both are locked behind purchases outside the game (an EA Play MVP+ subscription and a Madden NFL 27 unlock). Leaving one off hides that tree entirely, so a league where nobody owns it isn't scrolling past skills it can't use. Turning it on doesn't grant anything — it just stops hiding it. They're separate switches, because the two are bought different ways.
- dbNew
Coach, StaffAssignment and CoachSkillSnapshot tables, plus per-league switches for the module and the two paid archetypes. Coaches are stored per league rather than per member, so a coordinator who moves between two members' schools stays one person with one career.
- dbNew leagues now default to CFB 27. Existing leagues keep whichever version they were created with — nothing changes underneath a dynasty in progress.
- webCoaching staff now moves correctly when a solo dynasty is merged into a Discord league, along with everything else that follows you across.
Skill data covers all 13 College Football 27 archetypes and every ability in them.
Update Overalls — the once-a-year job, without screenshots
- webNew: Roster → Update Overalls. After progression, every player needs a new overall. This is a single screen for exactly that: your whole squad in one alphabetical list, last season's overall shown beside each input, and the change calculated live as you type. Press Enter to jump to the next player so you can work straight down the page beside the in-game roster without touching the mouse. One save for the whole roster.
- webSorted A–Z by surname by default, matching the roster page, so the list runs top to bottom the way the in-game roster does. Flip to By position when you'd rather think in depth-chart terms.
- webPosition and archetype are editable on the same screen, since Training Results and the offseason move players around. Archetype choices follow the position you pick.
- webA "only show players without an overall" filter, so a mid-season top-up doesn't mean scrolling past everyone already done.
- webTo be clear about what a rollover actually costs you: position, archetype, dev trait, class and redshirt status all carry forward automatically. Only the overall is cleared, because it's the one thing that changed. Re-importing roster screenshots is the fast path, never the required one.
Fixes
- webTransfer portal additions never reached your roster. Signed high-school recruits have had an "Add to Roster" action for a while; incoming transfers had none at all, so a player you won in the portal existed on the portal board and nowhere else. They can now be promoted the same way, carrying their position, archetype, dev trait and overall across.
Fixes — records that disagreed with each other
A review of how win/loss records are calculated across the app turned up three surfaces that each counted games their own way. All three now go through the shared resolver, so the record on your dashboard, in the standings, in a recap, and in Season History is the same number.
- webYour dashboard record was wrong for most coaches. It read the logged result straight off the game, and a game result is stored from the point of view of *whoever submitted it* — so when your opponent logged the game, their win was counted as your win. Forfeits, which have no logged result at all, were invisible and counted as nothing. Checked against live leagues: 11 of the 17 coaches who have completed games had the wrong record on their dashboard, including one shown at 1-0 who had actually lost. Uploaded box scores now take priority over a typed-in result here too, matching every other surface.
- botStorylines counted head-to-head games only. Recap records and win/loss streaks excluded games against the CPU, so a coach who was 8-1 on the season could be described as 2-0 in his own league's recap. Per-game narration is unchanged — that correctly asks which of two coaches won a specific game.
- webThe AP ranking on the dashboard now reads from an uploaded box score when there is one, instead of only the manually-entered rank.
Fixes — from a review of everything shipped today
- webArchived dynasties were not read-only. The data layer blocks *creating* rows in a finished era but not editing or deleting them, and the new Season History routes didn't check either — so a commish who switched to an archived dynasty could rewrite or delete its history permanently. All Season History writes now refuse an archived league, matching every other write route.
- webThe commish check disagreed with itself. Pages decided who's a commish from the league's commissioner list; the new API routes decided from the member record. A commish without a linked Discord account got the controls hidden but the API would have allowed them; a Discord commish whose member row said otherwise saw a "Re-derive" button that failed when clicked. Both now use the same rule as the bot — either source counts.
- webBackfilling an old season stamped it with your current school. A coach who has since changed jobs would see a season they played elsewhere filed under their new program. It now takes the school from the nearest recorded season, and a specific one can be supplied.
- webA season's record could be saved with absurd values (two billion wins would sort you to the top of the trophy room forever, and anything larger returned a server error). Wins, losses and blue chip ratio are now range-checked like every other field.
- webThe head-to-head card counted the game on the card in its own all-time series, so a played game showed itself as "the last meeting".
- webA game decided by forfeit still showed the score from whatever was logged before the forfeit — with the wrong coach highlighted as the winner, contradicting the result shown directly beneath it. Disputed results no longer render as a final either.
- webRoster development told a league that had already uploaded two seasons to go upload another, if nobody's overall happened to change.
- webTwo coaches recording the same season at the same moment now get a clear "already recorded" message instead of a server error, and editing a season no longer requires sending every field back.
Player development
- webEvery player's overall, season by season, on their profile. A timeline of the OVR you recorded each year with the change from the year before, the total gain across their career, and any position changes along the way. Redshirt years and seasons you didn't record an overall stay on the timeline without breaking the chain — the next real reading is compared against the last one you actually took.
- webDevelopment boards on your roster: biggest risers and biggest drops across your squad, and the best player at each position this season with their change since last year.
- webPosition changes are surfaced too — a safety who moved to linebacker shows up as
SS → WILL rather than quietly changing in the table.
- webThese boards need an overall recorded in two seasons, which no league has yet — rollover creates the new season without one, so it fills in after you re-import your roster next year. Until then the panels say so plainly instead of showing an empty table. Best at each position works right now, since it only needs the current season.
Fixes
- webPlayers with an unrecognized position are now flagged instead of hidden. OCR occasionally reads a position as
0B, UB or 1. Those rows counted as complete, but the position dropdown had no matching option so it rendered blank, and anything grouping by position skipped the player silently — including the new development boards, where your best quarterback could simply go missing. They now count toward the roster's incomplete tally, sort to the top with the other unfinished rows, and the dropdown shows the unrecognized value as 0B (unrecognized) so you can see what it currently is and correct it. Seven players across live leagues were in this state.
Logos on the Lounge
- webTeam logos and school colors across the matchups page. Every coach card leads with its school's crest and carries a strip of that school's color along the top edge, so a wall of cards reads as a wall of teams instead of a wall of abbreviations. Head-to-head cards put a large crest on each side and split both schools' colors across the top of the card. The CPU list gets crests too, for the coach and their opponent.
- webThe crest markup — tinted tile, school-colored border, logo centered — had been hand-copied across the app, each with its own hardcoded size. It's now one shared
TeamCrest component with size variants, and it keeps the old fallbacks: a dedicated tile for generic FCS opponents, and a stable per-team color for any abbreviation with no logo behind it. The schedule table, dashboard hero and league picker now render through it, so the logo on your schedule and the logo on the Lounge can't drift apart. Crests also carry the school name as a tooltip.
The Lounge — the matchups page is now about the people in your league
- webMatchups is rebuilt around who's playing who. Coach-vs-coach games lead the page as full head-to-head cards carrying the all-time series between those two coaches — the record, the last five meetings, and the last final. A week-6 game against the same guy you've split six games with should feel like it's part of something, and now it reads that way.
- webThe Lounge, underneath: a card for every coach in the league — team and current rank, season record, last-five form, active win/loss streak, ready or away status, Belt holder, and the national and conference titles from Season History. Your own card is highlighted.
- webGames against the CPU collapse into a single expandable list. They're part of the week; they're not why you open the page.
- webKickoff times, Twitch and YouTube links surface right on the matchup card, so you can find the stream without leaving the page.
- webForfeits and results logged without a score now show who won instead of an empty score line.
Fixes
- webThe matchups page had its own copy of the box-score bug fixed everywhere else in v1.12.1 — it compared a team abbreviation against a school name, so the score column was blank for 117 of 138 schools. Score reading now goes through
@dynastydesk/scoring like every other surface.
Season History — your dynasty, year by year
- [web/db] New: a Season History page. Every completed season in the league — record, conference record, final ranking, bowl, conference and national titles, plus the staff and program ratings you were running that year. Seasons are written automatically at the End of Season Recap with everything the app can work out already filled in; you complete the rest.
- webA Trophy Room at the top of the page: national titles, conference titles and bowl wins per coach, with the years, ranked by hardware.
- webAdd a past season for anything that finished before this existed, with a *Fill from games* button that derives the record from whatever games are already logged so you aren't retyping it.
- webSeasons start as a draft and can be re-derived as results are corrected; finalizing one freezes it against further automatic changes. A coach edits their own seasons, a commish can correct anyone's.
- dbNew
SeasonSummary model. It deliberately stores only what can't be recomputed — per-game rates stay derived from the stat tables, so a late stat correction can't leave history disagreeing with the stat pages.
Fixes
- [web/bot] Win-loss records now count games against the CPU. The function that decides who won a game only ever answered "which of these two coaches won", and returned nothing at all for a CPU opponent. Since most of a season is played against the CPU, a coach who went 12-1 with two head-to-head games showed as 2-0 in the standings. Standings, conference records, and season history now count every game a coach played.
- [web/bot] Conference championships, bowls and national titles won against the CPU now score in the Dynasty Cup. They were only credited when the title game happened to be against another coach — beating a CPU opponent for the natty was worth zero. Head-to-head bonuses (
uvuWin, rankedWin, rivalWin) stay head-to-head, which is what they mean.
- webConference records on the team page had the same hole: an 8-1 conference season with one head-to-head game read as 1-0.
Fixes
- [web/bot] Dynasty Cup now scores the postseason on the right weeks. The three weeks the Cup cares about were hardcoded, and all three were off by one — in both the bot and the web app at the same time. A conference championship win scored nothing at all, a Bowl Week 1 win was credited as a conference title (5 points instead of 3), and the national championship was credited as an ordinary bowl win (3 points instead of 8). Two of the "bowl" weeks pointed at offseason weeks that have no games. Nothing changes retroactively — no league in any dynasty has played a game past Week 15 yet, so no Cup total, ranking, or belt reign moves — but from the first conference championship onward the points land where they should.
- [web/bot] Those week numbers are now derived from the season schedule instead of being maintained by hand, so they follow the calendar automatically rather than silently drifting again. A test pins the mapping and checks the bot's and web app's copies of the schedule still agree with each other.
Fixes
- [web/bot] Game results read from a screenshot now count for every school. When a box score was uploaded for a user-vs-user game, the code that decides who won compared the team name on the screenshot ("GEORGIA") against the team's abbreviation ("UGA") — which only ever matched for the 21 of 138 schools whose name happens to be their abbreviation (BYU, USC, TCU, Iowa, Army…). For the other 117 the screenshot was quietly ignored and the result fell back to whatever was logged by hand. Standings, the Dynasty Cup, The Belt, and conference records all read from this. Nothing changes retroactively — no completed head-to-head game in any league currently has a box score attached, so no existing standing moves — but from now on an uploaded box score decides the game for everyone.
- webConference records had a second flaw of their own: when a box score was present but couldn't be matched to either coach, the game was skipped entirely instead of falling back to the manually-logged result.
- [bot/web] This logic had been hand-copied into four places, which is how one bug lived in all of them at once. It now has a single home (
@dynastydesk/scoring) that the standings, the Dynasty Cup, conference records, and Storylines all share — 122 fewer lines, and a regression test pinning the school-name case.
- botFixed the league's name showing as "the league" in every Storylines recap.
Storylines — your league gets a beat writer
- [bot/web/db] New: an AI-written recap of every week, posted to your league channel when you advance. It covers the games that were actually played — who beat whom and by how much, upsets, rivalry games, the stat lines worth mentioning, win and loss streaks, and any change of The Belt. Turn it on per league in Settings, where you can also pick a voice (beat writer, hype, snark, or a plain wire report) and write league context — running jokes, rivalries that aren't on the map, who talks the most trash — that every week's recap is told about.
- bot
/advance start takes a notes option for one-off context: an injury, a side story, a callout. The preview echoes the notes back before you confirm, so you can see what the recap is about to be told. Notes only apply to a manual advance; an automatic one uses your league context on its own.
- webRecaps appear on the dashboard and in a Storylines archive with every week of the season, and can be rated 👍/👎 there or with the reactions on the Discord post.
- botThe recap can't make things up. Every number and every name it writes is checked against the week's real data before anything is posted — a fabricated score or an invented player fails the check, the recap is rewritten once, and if it still doesn't hold up nothing is posted at all. Recaps are only written for weeks that had games; the offseason gets nothing.
- botA recap can never delay or break an advance: it's generated after everything else has posted, and any failure is logged and skipped. With no API key configured the whole feature is a no-op.
Fixes
- botStats submitted through Discord now link to your roster.
/stats submit was writing the raw name the OCR read, with no connection to the player it belonged to — so one misread of "T.Yates" as "T.Vates" created a second player and split that athlete's season in half. This is the same problem v1.11.0 fixed on the web, which the Discord path was left out of. Names are now matched against your roster *before* anything is saved, and the confirmation shows exactly what will happen: which names matched, which were corrected, and which are about to be added as new players.
- botWhen a name is genuinely unclear, you now pick. If two roster players fit equally well, or nobody does, the confirmation offers a dropdown per name — choose the right player, add a new one, or leave it unlinked. Submissions where everything matched are unchanged and still confirm with a single ✅.
- botTeam names from Discord submissions are normalized to the canonical school, so "Oregon St" and "Oregon State" stop splitting one team's stats.
- botFixing a misread name in Discord now teaches the app, the same way fixing one on the web already did — the correction is remembered and applied automatically next time.
- botFixed a crash on any reaction in the league channel between seasons. The status-board handler read the current week before checking whether there was one, so reacting to any message while no week was live threw an error.
- dbPlayers created from Discord stat uploads are now flagged for review, so they show up in the roster's "created from stat uploads" banner instead of appearing silently.
Fixes
- webThe roster now sorts by last name. Players are stored under their abbreviated in-game name ("T.Yates"), and every list was sorting on that string directly — which orders the roster by *first initial*, so T.Yates landed between S.Adams and U.Brown. Surname order now applies to the roster table, the archived rosters from former schools, the NFL Draft board, the award submission picker, and the name suggestions on stat entry. Clicking the Name column header already sorted correctly; this fixes the default order.
Fixes
- webImported players show up immediately. After a roster screenshot import the page refreshed behind the scenes, but the player table kept rendering the list it loaded with — you had to reload manually before the new players appeared. It now picks up server changes as they land, including after merging a duplicate from the "created from stat uploads" banner. Unsaved edits are never overwritten by a background refresh.
- webThe link from a roster row to that player's game stats is now an icon next to the delete button, instead of text crowding the name fields.
Your roster and your stats are finally the same players
- dbStats now point at roster players. Every stat row (passing, rushing, receiving, blocking, defense) carries a real link to the player it belongs to, instead of just a name. Before this, the roster and the stat pages were two separate lists that happened to share spellings — so a single OCR misread of a name created a second, phantom player, split that athlete's stats in half, and nothing could put them back together.
- webUploading stats checks names against your roster. In the review grid, a name that's slightly off now shows the roster spelling with one click to accept it, a name with two possible matches offers both instead of guessing, and a name that's genuinely new is flagged in red. A line above the Save button tells you exactly how many players are about to be created and names them, so nobody gets added by accident.
- webThe same check runs on manual stat entry, which previously only offered the roster as a soft autocomplete hint.
- webDuplicate players can be merged. The roster page now shows a banner for players that a stat upload created without anyone confirming them, with the roster player each one probably is — merging moves all their stats, awards, and records onto the right player and retires the duplicate.
- webRenaming a player takes their stats with them. Editing a name on the roster used to leave every past stat row on the old spelling, silently splitting the player in two.
- webAwards and record holders link to the roster too, so an award follows the player instead of a name string.
- webRoster rows link straight to that player's stat profile, and profiles are now addressed by player rather than by a name-and-team string, so they survive a rename.
- ocrThe Claude re-read of low-confidence fields actually runs now. It compared a
0.7 threshold against confidence scores that are on a 0–100 scale, so the check was never true — the fallback fired, spent an API call, and corrected nothing.
- webTeam names on stat submissions are normalized to the canonical school on save, so "App State" and "Appalachian State" stop splitting one team's stats.
- dbAdded indexes to the five stat tables, which previously had none — every leaderboard was a full scan of a text column.
- webLearned name corrections are reused: once you fix a misread name, the same misread resolves automatically next time.
Fixes
- ciA failed release now says so in Discord. The
changelog.html publish runs last in auto-release.yml and needs a separate token (DDWEBSITE_PAT) — so when that token expired, the tag, the GitHub release, and the Discord release post all still succeeded and only the marketing site's changelog quietly stopped updating. It stayed unnoticed from v1.9.2 through v1.10.0. The workflow now posts a failure notice to the same Discord webhook on any failed run, naming the step that broke, distinguishing an expired/under-scoped token from a push problem, and including the one-line local recovery command.
Player Settings: your account and your coach profile, on the web
- webNew Player Settings page at
/me, linked from the bottom of the sidebar and from the "choose a dynasty" screen. It splits into what's true everywhere (your account) and what's true in one dynasty (your coach profile).
- webEdit your gamertag, Twitch, and YouTube from the web. These were previously only settable with
/roster set in Discord, so anyone who never used the bot had no way to fill them in. Bare handles get expanded the same way the bot expands them — typing alexc into Twitch stores https://www.twitch.tv/alexc — and clearing a field works with an empty box or by typing clear.
- webConnect your Discord account from Settings. Linking used to be reachable only from the merge screen. Connecting lets DynastyDesk recognize you as one coach across the bot and the website.
- webMerging a solo dynasty now has a permanent home, with a plain-language explanation of what merging moves and what happens to the solo league afterward — instead of only appearing as a banner you might have dismissed.
- webYour team and away status are shown on the page as read-only, each pointing at the right place to change them (the Dashboard's coaching-job card during bowl weeks, and
/away in Discord).
- webIf you play in more than one dynasty, Player Settings opens straight from the dynasty picker without making you choose first, and lets you switch which dynasty's profile you're editing in place.
Fixes
- webGame times now show in your own timezone. A game scheduled for 9:00 PM CT was displaying on the web dashboard as 2:00 AM — the stored times were correct, but the site was rendering them in UTC (the server's timezone) instead of a real one. Every date and time on the site now follows your browser's timezone, matching how times have always appeared in Discord. Fixed on the dashboard, the season schedule, the matchups list and detail pages, "Submitted" timestamps, member away-until dates, and the admin submission views. Date-only fields are corrected too — a UTC-rendered date near midnight could land on the wrong day.
Smoother stat uploads: right week, one team, fuller box scores
- webThe week you click "Add Stats" on now carries all the way into the screenshot importer — the first game defaults to that week instead of jumping to the last week of the season.
- webSet your team once per game. The stat-upload review now leads with the box score and a single matched team dropdown that applies to every stat table, so an OCR misread (e.g. "Oregon St" vs "Oregon State") is fixed one time instead of on every screen. The box score's home/away teams are matched dropdowns too, so team names always line up with your schedule.
- webFixed rushing carries showing blank in the review table for post-game / team-view screenshots — attempts were being read into a hidden column.
- webDefensive secondary stats are now visible and editable. Pass deflections, forced fumbles, fumble recoveries, blocks, safeties, and defensive TDs from the second defense screen now appear in the (horizontally scrollable) defense table instead of being saved out of sight.
Fixes
- ocrFixed the CFB 27 post-game box score (Team Stats tab) failing with "Screen not recognized." Its quarter-by-quarter scoreboard header shifts the Score row down relative to the after-exit view, so the classifier and score reader now handle both box-score layouts. Final scores read reliably; team names/ranks in the stylized font remain flagged for quick review as before.
Fixes
- ocrFixed CFB 27 post-game Player Stats screens (the in-game view with a TEAM column and the player card on the right) failing with "Screen not recognized." The classifier now handles both CFB 27 stat layouts — the immediately-after-game view and the after-exit Season Summary view — whose stat-type pill and column headers sit at different on-screen positions. Rushing screens, which label attempts as
ATT, are now detected reliably.
- webIn bulk stat upload, a skipped screenshot's error message is now readable in full on hover (it was cut off), and fixed the spacing in the "N screenshots couldn't be read" summary.
Fixes
- webFixed the sidebar Admin Console link returning a 404 —
/admin now lands on the admin section instead of an empty route.
Result editing, a cleaner schedule, and a rebalanced dashboard
- webEdit a game result after saving it — an Edit button reopens the entry form pre-filled with the score, ranks, and opponent, so fixing a mistake no longer means deleting and re-adding the game.
- webWhen logging a CPU game, the opponent is now a searchable team dropdown instead of a free-text box — faster, and the opponent's logo now shows on your schedule.
- webCleaner home/away: away games are marked with an
@ before the opponent's name (home shows nothing), the way schedules are normally written. The opponent's AP ranking shows as (N) after their name, and your own ranking as a compact chip by the score.
- [web][db] CPU home/away now sticks — previously a CPU "away" game reverted to unknown on reload. Added
Matchup.userIsHome so CPU games (which have no opponent user) can record their side.
- webSimplified the location picker — removed the "Unknown" option and default new games to Home. Editing an existing game leaves its location untouched unless you pick one.
- webGame results can no longer be saved as a tie (college football has no ties).
- webRebalanced the dashboard header — your record and ranking now sit in their own region, with the commissioner's Advance Week controls cleanly separated, instead of crowding one corner.
- webBigger, centered sidebar logo and a thin, muted scrollbar for the navigation.
- webGroundwork for a consistent look — shared card/badge/result components and shared win/loss/tie + elevation styling, rolled into Standings and the League Stats pages first.
- webHardened spreadsheet import error handling.
Wider layout + bigger brand mark
- webWidened the main content area so pages use more of the screen on larger displays instead of leaving big empty gutters on either side.
- webEnlarged the sidebar logo into a clean circular emblem with the DynastyDesk wordmark centered beneath it.
Polish & fixes
- webFixed the league switcher overflowing the sidebar — long dynasty names no longer push the dropdown outside the left navigation.
- webGave the Offseason Roadmap a premium refresh to match the new dashboard: a brand-color wash and accent rail, a glowing current-step marker, and team-colored completed steps.
- webReplaced the leftover default favicon with the DynastyDesk logo (browser tab + Apple touch icon).
Import from a spreadsheet
- webNew Import Spreadsheet tool (sidebar → My Team): already track your dynasty in Excel, Google Sheets, or a CSV? Upload your workbook — every tab at once — and DynastyDesk reads all of them. Tag each tab as your roster or a recruiting class, and we auto-match your column headers to the right fields (fix anything we guess wrong from a dropdown).
- webBefore anything saves, a review step flags rows that look off — unknown positions, out-of-range overalls, missing class years, and likely duplicates (within your sheet and against players already on your team) — and lists which columns won't be imported. Edit inline or uncheck any row, then import.
- webRe-importing is safe: roster rows update existing players instead of duplicating them, and recruit imports now skip duplicates already on your board. Accepts .xlsx, .xls, .csv, and .ods; everything imports to your own team.
Consistent page titles
- webEvery page now uses the same larger, bolder title style, so headings across roster, recruiting, stats, records, awards, standings, and settings read with consistent weight and hierarchy.
Stat leaderboards
- webThe league Team Stats and Player Stats pages now open with a top-8 leaderboard chart for the selected category — horizontal bars scaled to each stat and colored in every team's brand color, with your own team highlighted. The full sortable table still sits right below.
A real dashboard hero
- webYour dashboard now opens with a team hero band — your school's crest and brand colors, your season record, current AP ranking, and your next game (opponent crest, home/away, kickoff time) front and center, plus quick-stat tiles for record, ranking, and conference.
- webWhen the season's done, the hero shows your last result instead of a next game.
- webCommissioners get the weekly readiness count and Advance Week control right inside the hero, so the core weekly loop leads the page.
A cleaner, bolder dashboard
- webFixed a font-loading bug that was silently rendering the entire app in the browser's default serif instead of the intended Geist typeface. Text across the dashboard now looks the way it was designed to — with a proper size-and-weight hierarchy and larger, clearer page titles.
- webSection pages with multiple views (Recruiting, Roster, Stats, Records, Awards) now show an in-page tab bar, so the sibling pages are obvious at a glance instead of only appearing in the sidebar after you click in.
- webThe DynastyDesk logo now takes you back to your league picker — a one-click way to switch between or add dynasties from anywhere in the app.
- webThe league picker now shows each team's real logo on its brand color (instead of a generic trophy), with warmer welcome copy.
- webSeparated the Support / Sign out area into its own panel so it's no longer mistaken for part of the navigation, and consolidated the app-admin entry point into a single Admin Console link.
- webLaid the groundwork for a broader visual refresh: a real type scale, card elevation, and shared UI tokens.
Tidy up the solo→league merge entry point
- webMoved the Merge into a Discord league link out of the primary sidebar navigation and into the quieter footer (next to Support / Sign out), so solo players who'll never join a league aren't shown it perpetually. The one-click merge banner still appears automatically the moment a merge is actually possible.
- webGave the solo Advance Week button a bolder, filled style so the core weekly loop stands out on the dashboard.
Merge a solo team into your league
- webYou can now track your team solo and merge it into your league later — with no re-entry. If you start a solo dynasty before your commissioner adopts DynastyDesk, then get added to the league (
/roster add in Discord), the app offers a one-click merge that folds your entire solo history into the league: roster and player progression, recruiting, transfer portal, full schedule, results, stat sheets, and personal records — across every season. A preview shows exactly what will move before you confirm, and your solo copy is archived afterward.
- webConnect Discord to your account — link your Discord login to an existing email or Google account with a quick OAuth approval (nothing to type). This is how the app matches you to the league spot your commissioner created for you.
- dbAdded
League.mergedIntoLeagueId to mark a solo league that has been folded into another and hide it from your league switcher.
Support DynastyDesk — optional donation links
- webNew Support DynastyDesk link in the app sidebar and mobile menu, pointing to Ko-fi for optional one-time tips or monthly support that help cover hosting and storage costs. Kept unobtrusive — a single quiet link, no banners or pop-ups.
- botNew
/support command — an ephemeral card with Ko-fi and GitHub Sponsors links for anyone who'd like to help keep DynastyDesk running. Also surfaced in /help. Always optional.
Social Studio — visual queue + post maker
- webNew admin Social Studio (
/admin/social): a visual dashboard for the evergreen social pipeline that used to require hand-editing JSON. It reads and writes calendar.json straight through the GitHub API, so the existing Mon/Wed Buffer drip keeps running untouched.
- webQueue dashboard — upcoming posts in order (the next one badged Posts next), each with pillar, template/hero, platforms, an expandable caption preview, and one-tap edit / delete / move up-down / duplicate. Plus a recently-posted history and a weekly-digest panel showing which CHANGELOG versions the next Friday digest will draw from.
- webPost maker — a form for title (auto-slugged id), pillar, template-card vs hero-image, body, hashtags, and platforms, with a live caption preview. Writes a clean entry to the queue; all commits carry
[skip ci] so they never trigger a release.
- webHero image upload — upload a PNG/JPG in the post maker and it commits to the marketing site's
social/heroes/ and auto-fills the filename, so hero posts no longer need a manual file drop. Gated to app admins.
Fix — roster import dropped first names
- webFixed the roster table showing no first names after a screenshot import, even though the import reported success. The import saved full first names to a dedicated
Player.firstName field, but the roster page was still deriving the first-name column from the abbreviated name key and never read that field — so typed first names were saved but hidden. The table now reads and writes firstName directly. First names saved on earlier imports now appear retroactively, no re-import needed.
- webYou no longer need to strip the leading initial off the last name in the import review step — the abbreviated name (e.g.
R.Colton) stays as the stable identity key that re-uploads match on, and the full first name is stored alongside it.
- webFixed roster import collapsing two different players who share a surname (e.g.
E.Smith and J.Smith) into a single row. Import de-duplication now keys on name and position.
The Belt — reign history + Hall of Fame
- webThe Dynasty Cup page now shows a Belt History section: a Hall of Fame (longest reign, most defenses, most reigns), a per-coach belt résumé (reigns, defenses, weeks held, longest reign), and the full title lineage. The current-holder banner now shows the active reign length and defenses.
- botNew
/cup belt history command — Hall of Fame plus the recent title lineage. /cup belt show now reports the current reign length (in advances) and defenses.
- [bot/db] The Belt now tracks successful defenses — each advance where the holder wins their head-to-head game. Added
BeltReign.defenses. Reign length is measured in advances (game weeks).
Dynasty Cup — settings polish + web point adjustments
- webAdjust Cup points from the web. Commissioners now get an Adjust points panel on the Dynasty Cup page — pick a coach, enter points (positive or negative), and a reason that shows in their breakdown. Same as
/cup adjust in Discord, no Discord required.
- webCleaner Cup settings. Point values are now grouped (Game results / Accomplishments) with +/− steppers instead of cramped number fields, and enabling the Cup now surfaces it in the sidebar immediately.
- webEasy Belt icon picker. The Belt settings now offer a row of one-tap emoji presets (no emoji keyboard needed on desktop), with a text field for a custom/pasted icon.
- [web/db] Belt default icon changed from 🏆 to 👑 so it no longer looks like the Global Record Book contributor badge. Existing leagues on the old default were migrated.
Fix — Dynasty Cup settings page wouldn't load
- webFixed the /settings page failing to load. The client-side settings form imported the Cup scoring module, which transitively pulled server-only database code into the browser bundle. The pure scoring constants now live in a separate
cup-scoring module that's safe to import from client components.
Dynasty Cup + The Belt — a cross-season competition layer
- webDynasty Cup. A new cross-season points ladder among coaches, at /cup. Points come automatically from user-vs-user wins (with ranked-win and rival-win bonuses), bowl/conference/national titles, awards, top recruiting classes, and players drafted — with a per-category breakdown. Win/loss resolution reuses the standings engine (forfeit → OCR box score → confirmed result), so the two always agree.
- bot
/cup command. /cup standings [year] shows the ladder; /cup adjust <user> <points> <description> lets commissioners grant or deduct points for anything untracked; the Cup top-5 is posted to the league channel on each advance.
- webThe Belt. An optional dynasty-long championship belt one coach holds until beaten head-to-head. Configurable name, emoji, and seed mode (commissioner-assigned by default; also first-win, season-1 Cup leader, or random). The holder is badged with the belt emoji on the Cup page and in the Discord status board.
- bot
/cup belt show / give and automatic transfers: when the holder loses a UvU game the belt moves to the winner (reign history tracked); a "new champion" line posts on advance.
- webCommissioner Settings page at /settings — the first per-league web config surface — hosts Dynasty Cup point values and Belt configuration. The Cup is hidden entirely for web-only members.
- dbAdded
League.cupEnabled/cupScoring and League.beltEnabled/beltName/beltEmoji/beltHolderId/beltSeedMode, plus new CupAdjustment (points ledger) and BeltReign (belt history) tables.
Stat import — game grouping, full opponent names, off-schedule warning
- webMulti-game grouping is now order-independent. A game is grouped by its screens (one box score, one of each stat type), so a game whose box score you uploaded last — or not at all — no longer gets split into two. Previously anything before the box score became a phantom second "game."
- ocrFull opponent names. Box scores now read multi-word team names in full — "Georgia Tech", "San Jose State", "Ole Miss" — instead of just the first word ("Georgia"). This also fixes the recorded game result showing the wrong opponent.
- webOff-schedule heads-up. If a game's box score doesn't match the opponent scheduled for the week you picked, the review step now shows a non-blocking warning so you can double-check the week — you can still save.
Stat import — post-game screenshots taken after leaving the game now work
- ocrFixed stat/box-score screenshots failing to import when taken from the stats screen you reach after exiting a game (the single-team "Player Stats" view, no TEAM column). Every screen — box score, passing, rushing, receiving, blocking, and both defense screens — was coming back as "No stat screens detected"; that whole layout had never been calibrated for CF27. It now reads and drops you into the same review-and-edit step as the immediately-after-game view. (The after-game view was unaffected and still works.)
- ocrFixed two reader bugs that were dropping rows: player-stat tables were being cut off at their first row (a name longer than 8 characters was mistaken for the end of the table), and the highlighted top row's noisy scan could abort the whole table. Non-highlighted rows now read cleanly; the highlighted row and the odd misread digit are flagged low-confidence for you to fix in review, same as everywhere else.
Web — hide "vs. User" in solo web leagues
- webFixed the schedule editor showing a vs. User option in single-user web leagues. Web-only members have no linked Discord ID, so the self-exclusion for the opponent list never matched them and left you in your own opponent list — now you're excluded by member identity, so solo leagues correctly show only vs. CPU while multi-user leagues are unaffected.
Roster screenshot import — accuracy overhaul, review step, offseason resync
- ocrRoster screenshots now import your whole roster instead of dropping about half of it. The reader no longer throws out a player just because his OVR didn't scan — it keys on the name instead — so the highlighted top player of each screenshot and everyone in the 70s (which the CF27 font misreads) come through.
- ocrQB now reads correctly instead of coming through blank (the game's "QB" was scanning as "0B").
- webNew review & edit step before anything is saved: the parsed roster opens in an editable table so you can fix names, class, position, and OVR — with a "needs a look" count and a Show only flagged filter — then Import.
- webLow-confidence flagging. Rows are highlighted when the OVR, position, or class didn't scan cleanly, when a name's initial looks off, or when a near-duplicate of another row is detected (an overlap between two screenshots).
- webOVR auto-fill. Blank OVRs (the font's 7→1 misread) are reconstructed from the roster's high-to-low sort order and pre-filled in italics/yellow for you to confirm — cutting the numbers you have to type by roughly two-thirds.
- [web][ocr] Archetype, Dev Trait, and full first name are now captured from the right-hand detail panel for the highlighted player on each screenshot, editable in the review table.
- webSafer re-uploads. Re-uploading your roster (e.g. after Training Results or Position Change weeks) now matches players by name — including close OCR variants — and updates them in place instead of creating duplicates.
- dbAdded
Player.firstName (optional) to store the full first name read from the roster panel.
Web — matchups cleanup, unified stat import, roster sorting, smoother new-dynasty setup
- webMatchups page now lists only weeks that actually have games — preseason and offseason weeks are gone — and no longer shows a duplicate Week 0 from a past season. The long row of week chips is replaced by a compact week dropdown with ‹ prev / next › arrows.
- webImport Stats and Bulk Import are now one page. The uploader groups your screenshots into games automatically, so a single game and a whole week's worth work from the same place — no more choosing which import to use.
- webRemoved the "College Football 27 — OCR may read incorrectly" banner now that CF27 calibration is done. Because stat screens read by layout ratios, Xbox screenshots work the same as PS5, with the Claude-vision fallback backing up tricky reads.
- webSortable roster columns — click Name, Year, Position, Dev Trait, or OVR to sort your roster; click again to flip direction, once more to clear. Sorting never disturbs inline editing, and unsaved new players stay pinned to the bottom.
- webStart a dynasty already in progress. The create-a-dynasty page now has an optional *"Joining a dynasty already in progress?"* section to pick your current year and week, so you don't have to manually advance to catch up.
- webTeam logos in the new-dynasty picker sit on a light chip now, so dark navy/black logos are no longer hard to see against the card.
- webThe offseason roadmap no longer appears on a brand-new dynasty's first Preseason — it shows once there's a completed season behind it.
Web — Games Played counts games, not stat lines
- webFixed the GP column on Player Stats undercounting games. It was counting only the games in which a player recorded that specific stat, so a QB who threw in every game but ran in only one showed GP 2 on the Passing tab and GP 1 on Rushing. Games Played now reflects the games a player actually appeared in (across every stat category) and reads consistently on every tab and on the player detail page.
- webNudged the slide-out Feedback tab lower down the right edge so it no longer overlaps the stats table and other page content.
Web — move games between weeks, drop Preseason from stat entry
- webAdded a Move control on each row of your team's game log to re-tag a game to a different week — for fixing games logged against the wrong week.
- webPreseason is no longer offered as a stat-entry week (it's an offseason/admin week with no games); game weeks now run Week 0 → National Championship.
- webFixed solo (web-app) leagues being unable to save a screenshot upload to a not-yet-active week — the upload path now honors the same relaxed week rules as manual entry.
Web — stats fixes: week labels, per-game averages, solo week entry
- webFixed week labels across the stats pages. Manual/upload week pickers and every game-log row now show the real week name (Preseason, Week 0, Week 1, …) instead of the raw internal week number, which was off by two.
- webFixed per-game averages counting each screenshot as a separate game. A game submitted as several screenshots (passing / rushing / defense) is now counted once, so team and player per-game stats, the GP column, and record projections divide by the correct number of games.
- webSolo (web-app) leagues can now enter stats for any week of the season, not just the active or completed ones — pick Week 0 during Preseason and log it, with a heads-up when the week isn't your active week yet. Discord leagues are unchanged (advancing stays the bot's job).
- webOffensive line positions can now be split by side. LT/RT and LG/RG are selectable alongside the generic T/G on every roster and recruiting screen; screenshot imports stay generic and you opt into a side by editing the player.
1.0 — full College Football 27 support 🎉
- webNew in-app feedback widget — a slide-out tab on every page lets any user report a bug or share an idea, with an optional screenshot, delivered straight to the team with their league and current page attached for context. No Discord account required.
- webbotocrv1.0.0 marks complete College Football 27 support across DynastyDesk: post-game stats, box scores, rosters, the full CFB Records book, the Players Leaving / Draft card, and one-shot recruiting-class import all read CF27 — with an optional Claude-vision fallback for the game's trickiest fonts. It lands alongside the recent offseason roadmap & calendar, the coaching carousel, and dynasty eras. Thanks for playing.
OCR — LLM-vision fallback for low-confidence reads
- ocrbotwebAdded an optional Claude-vision fallback. When Tesseract returns low-confidence fields on a post-game stat screen (or a box-score warning), that one screenshot is sent to Claude Haiku to re-read just those values from the image — directly targeting College Football 27's inherent font misreads (7↔1, 5↔0, dropped digits). It overwrites only the flagged fields; your review table stays the final gate. Entirely opt-in and off by default: it activates only when an
ANTHROPIC_API_KEY is set and is a silent no-op otherwise (~$0.005/screen when enabled).
Web/OCR — recruiting-class import + OCR accuracy analytics
- webocrNew Import Recruiting Class flow — upload a College Football 27 "Top Classes Details" screenshot and DynastyDesk reads the whole class (name, position, star rating, national & state ranks) into an editable review table, then adds every recruit to your HS board at once. Look for "Import class from screenshot" on the recruiting board.
- webNew admin OCR Accuracy page (
/admin/ocr) over the correction flywheel: most-corrected fields, top misreads (OCR → saved), and a confidence-threshold calibration readout showing how often ≥70%-confidence reads were still wrong.
Web — offseason roadmap redesign
- webPolished the dashboard's offseason roadmap. A filled gold→blue progress track (plus a header progress bar and current-phase pill) replaces the thin connector line, numbered stepper markers show done / current / upcoming at a glance, action lists are now consistently left-aligned, and the type is larger and more legible. Completed steps and the progress fill pick up your team color when it's set.
Web — remove the unused Activity panel
- webRemoved the "Recent Activity" panel from the dashboard, plus the empty Activity page and its sidebar link — nothing ever wrote to the audit log, so they were always empty. The schedule/roadmap now spans the full width. (The audit-log table is retained for possible future use.)
Bot/Web — coaching job change also clears recruiting board & portal
- botwebWhen a coach takes a new job (applied at End of Season Recap), their recruiting board and transfer portal now archive along with the roster — both pages start empty at the new school. The Scouting Board is deliberately left intact.
Bot/Web/DB — offseason calendar & coaching carousel
- botThe offseason is now a first-class stretch. During offseason weeks (End of Season Recap → Players Leaving → Portal → NSD → Training → Encourage Transfers) the bot no longer opens a weekly CPU-games thread, and the weekly board becomes an action checklist — the tasks for that week plus what's next — instead of a game scoreboard
- botNew
/offseason command prints the full between-seasons calendar (Conference Championships → Preseason) with each week's actions and a "you are here" marker
- botNew
/roster takejob — a coach (or a commish on their behalf) can accept a new job during bowl weeks; the move is staged and applied automatically at End of Season Recap
- webThe dashboard now shows an offseason roadmap above the schedule during the between-seasons stretch, tracking progress week by week (tinted to your team's colors when available), with the full schedule collapsed by default but one click away
- webNew Coaching Carousel card to accept a new job from the dashboard during bowl weeks, and a read-only Previous Schools section on your roster that keeps a former school's roster viewable after you move. The NFL Draft Board gains a School column so you can see every player you've sent to the pros and where they came from
- dbAdded the "End of Season Recap" week to the season calendar (weeks renumbered 30 → 31). Added a per-player school snapshot, coach "pending job" fields, and a
CoachTenure history so a coach who changes schools keeps old rosters/stats attributed to the right school while each school's records stay put
OCR — CF27 records, players-leaving card & recruit star ratings
- ocrwebThe CFB Records import now works on College Football 27 (version-aware — CFB 26 records are unchanged). Recalibrated the whole table for CF27's restyled layout, and the Career / Season / Game level is now detected automatically and reliably from the highlighted tab — so scope and level are pre-filled for you across all nine national/conference/school × career/season/game views
- ocrThe "Players Leaving" player card now reads correctly on CF27 (version-aware). The same card also appears on the Draft Results screen, so both are covered
- ocrRecruiting "Top Classes Details" now reads each recruit's star rating (the graphical 1–5 stars), completing the recruiting-class table parser — the web import screen remains the one follow-up
OCR — CF27 roster + recruiting-class support
- ocrRoster screenshots now read correctly on College Football 27 (version-aware — CFB 26 rosters are unchanged). Around 8–9 players parse per screenshot; the highlighted/selected row can occasionally need a manual fix and is covered by the existing de-duplication across multiple scrolled screenshots
- ocrAdded a parser for CF27's "Top Classes Details" recruiting screen that reads the entire recruiting-class table — every recruit's name, position, offer, national & state rank, class, and state. (Star ratings, which are graphical, and the web import screen are follow-ups)
Web/DB — archived dynasties are read-only
- dbwebArchived prior-era dynasties are now genuinely read-only. New data (stats, players, records, recruits, awards, matchups, etc.) can no longer be written into a past dynasty — enforced at the data layer for both the web app and the bot — season advance is blocked on archived leagues, and the app shows a clear "read-only" banner while you're viewing one. Browsing the old dynasty's history still works exactly as before
- botFixed
npm run bot:deploy failing with a Prisma "Invalid value undefined for datasource" error — the command now loads the repo-root .env no matter which directory it runs from
Bot/DB/Web — dynasty eras (start a new game without losing history)
- botNew commish command
/dd newdynasty — start a fresh dynasty era (e.g. moving from CFB 26 to CF27) on the same server. It archives the current league as read-only history, spins up a new one at week zero, and carries your crew over (Discord members kept, team picks cleared to re-draft). Player rosters, stats, records, and awards start empty for the new era; nothing from the old one is lost
- botNew commish command
/dd gameversion to set an existing league's game version (CFB 26 / CF27) directly
- webThe league switcher now labels each entry by game version and flags archived (read-only) eras, and defaults you to the current era — so a server's past and present dynasties are easy to tell apart and browse
- dbA Discord server can now hold multiple leagues over time (one active, prior eras archived): added
active, archived, and previousLeagueId to leagues and lifted the one-league-per-server limit
OCR/DB — game-version awareness (CFB 26 + CF27 side by side)
- dbAdded a
gameVersion field to each league (defaults to CFB 26 for all existing leagues). Newly created leagues — via /dd setup or a solo web league — start on CFB 27
- ocrStat and box-score templates now carry both CFB 26 and CF27 coordinate sets and pick the right one per league. This restores correct parsing for CFB 26 leagues (v0.9.18 had switched the coordinates over to CF27) while keeping CF27 leagues on the new layout
- webbotScreenshot uploads (web multi/single upload and the Discord
/submit) now read each league's game version and OCR accordingly
- ocrFixed detection of CF27's "Players Leaving" screen (its title moved, so it had been falling through as an unrecognized screen)
OCR — College Football 27 support
- ocrRecalibrated the screenshot pipeline for College Football 27. All seven post-game screens — box score, passing, rushing, receiving, blocking, and both defensive scrolls — are re-mapped to CF27's restyled layout (repositioned stat tables, the new on-screen TEAM column, added LONG columns, and the redesigned center-scoreboard box score) and verified against real 4K PS5 captures
- ocrReworked screen detection for CF27: layout is now keyed off the TEAM column header, and the box score is positively identified by its "Score" label — fixing screens that previously failed to classify
- ocrSharper digit reading on stat rows (higher-resolution crops tuned for CF27's textured row backgrounds) — recovers many values on the highlighted/selected row and both final scores on the box score. Remaining ambiguous reads are still flagged low-confidence for quick correction in the review table
Web — unsaved-changes warning on the roster
- webEditing your roster (positions, OVR, dev traits, NFL draft designations, etc.) and then navigating away without saving now prompts a confirmation instead of silently discarding your changes. The warning covers in-app navigation — the sidebar, mobile drawer, header, and league switcher — as well as closing the tab or refreshing. It only appears when you actually have unsaved edits
Web/Bot/DB — NFL Draft & early departures
- webYou can now mark your own players as leaving for the NFL and record the round they were drafted (Round 1–7 or UDFA) — a new NFL Draft column on your roster, with a
→ NFL tag on anyone who's declared. Works for seniors and underclassmen alike
- webNew NFL Draft Board view (under Roster) listing every player from your program who left for the NFL, their round, draft year, and the class/position/OVR they left as. Drafted players stay on the board as alumni even after they've left your active roster
- botwebSeason advance now removes drafted underclassmen from the roster instead of progressing their class — mirroring how seniors graduate. A drafted player is pulled at the next rollover regardless of class; undrafted players advance as before. Applied to both the Discord bot advance and the solo-league web advance so the two stay in sync
- dbAdded
draftedYear and draftRound fields to the player record to track early departures and draft results
Web — mobile drawer opens from the right
- webThe mobile navigation drawer now slides out from the right to match the hamburger button's position (it previously opened from the left while the button sat top-right). Keeps the button and the drawer's motion on the same side and improves right-thumb reach
Web — PWA status-bar overlap fix
- webFixed the mobile header and drawer colliding with the iPhone status bar (battery/wifi/clock) when the app is launched from the home screen as an installed PWA. The standalone web view fills the whole screen including under the status bar, so the top bar, drawer, and page content now pad themselves by the iOS safe-area insets (
env(safe-area-inset-*)) to sit clear of it
Web — mobile experience + multi-game bulk stat upload
- webThe dashboard is now navigable on mobile: a hamburger button in the top bar opens a slide-out drawer with the full navigation, league switcher, and sign-out. Previously the sidebar was hidden below
md and phones had no way to move between pages. Applied to both the league and app-admin layouts (admin had no mobile chrome at all)
- webNew Bulk Import flow (
/stats/upload/bulk) — drop or select every screenshot from several games at once. Each shot is read individually with live per-file progress, results are auto-grouped into games (a box score starts a new game), defense screens are merged, and each game gets its own review card + week selector before a single Save All. Removes the need to run the single-game upload once per week when catching up a backlog
- webBulk save is blocked with a clear warning if two games target the same week, since a week only holds one submission per user (prevents silently overwriting stats)
- webInstallable as a PWA — added a web manifest, maskable icon, theme color, and iOS standalone metadata so the app can be added to the home screen and launched full-screen, making the phone-first upload workflow feel native
- webMobile comfort polish: fixed content being clipped under the fixed mobile header, tighter small-screen padding, larger tap targets on editable stat cells, and horizontal scrolling for the dense team-stats table
- ocrAdded a per-file OCR endpoint (
/api/ocr/single) backing the bulk flow; the single-game upload path is unchanged
Web/Bot — favicon fix + status board polish
- webFixed the browser tab icon defaulting to the generic favicon — the manual
metadata.icons config pointed at /icon.png, which the Next app-icon convention doesn't actually serve there (it uses a hashed /icon URL), so the link 404'd. Removed the override and let Next auto-inject app/favicon.ico + app/icon.png correctly (hard-refresh to clear the cached default)
- botStatus board no longer prefixes every ready/not-ready player with the section's emoji — the ✅ / ⏳ now live only on the section headers, cutting visual noise in the readiness list. Away members keep the 🏝️ marker since it distinguishes them within the combined ready count
Web — advance readiness view
- webThe League page now shows an Advance Readiness card for the current week: the "all ready to advance" / "waiting on N" verdict, ready count, advance countdown, a forced-win-pending warning, and Ready / Not Ready / Away member lists — so anyone can answer "can we advance?" without opening Discord
- webReadiness now mirrors the Discord status board exactly via a shared helper — away members count toward the ready threshold, and the commish dashboard's ready count was corrected to match (it previously undercounted away members)
- botOCR worker and per-session image cache are now always torn down after a
/stats submit (moved into finally), including on error paths — prevents a lingering Tesseract worker or cached decodes after a failed submission
Bot/Web — error tracking + uptime monitoring
- botAdded Sentry error tracking — unhandled rejections, uncaught exceptions, login failures, and every slash-command/autocomplete error are now reported with command + user context. No-ops unless
SENTRY_DSN is set
- botScheduler now pings a Better Stack heartbeat (
BOT_HEARTBEAT_URL) once per 15-minute tick so a crashed or hung cron loop pages instead of failing silently; each check in the tick is isolated so one failing check no longer aborts the rest of the tick
- webAdded Sentry error tracking for Server Components, Route Handlers, Server Actions, and the browser (via
instrumentation.ts / instrumentation-client.ts). No-ops unless SENTRY_DSN / NEXT_PUBLIC_SENTRY_DSN are set
- webNew
/api/health endpoint that returns 200 only when Postgres is reachable, for an external uptime monitor
CI — auto-publish the website changelog page
- ci
auto-release.yml now checks out the ddwebsite repo, regenerates changelog.html from this file via a small Node script (scripts/build-changelog.js in that repo), and pushes it — so dynastydesk.gg/changelog.html stays in sync with every release automatically, with no manual step
CI — restore Discord release notifications
- ciNo Discord release post since v0.9.1, silently:
auto-release.yml creates releases with the default GITHUB_TOKEN, and GitHub doesn't let GITHUB_TOKEN-triggered events cascade into other workflows — so release-changelog.yml's release: published listener never fired for any release the bot created (v0.9.2–v0.9.7). auto-release.yml now posts to Discord itself right after creating the release, instead of depending on that cross-workflow trigger
Web/Bot — conference data fixes, realignment page polish
- webbotCorrected default conference for 9 schools to match current real-world alignment (ESPN standings): Boise State, Colorado State, Fresno State, San Diego State, Texas State, and Utah State now default to Pac-12; Northern Illinois and UTEP now default to Mountain West; Louisiana Tech now defaults to Sun Belt. Existing leagues that haven't customized a school's conference pick up the corrected default automatically
- webConference realignment page — school logo cards now sit on a solid white backdrop with a thicker team-color border instead of a low-opacity color tint, fixing poor contrast for teams whose color was hard to see against the tinted background
- webConference realignment page — conference columns now size to their content (narrow when showing logos only, wider when "Show school names" is on) instead of a fixed grid, so more columns fit on screen at once when collapsed
Web — custom conference realignment
- webNew commish-only "Conferences" admin page (
/league/conferences) — drag schools between conference columns (logo cards with a toggle to show school names) and Save to realign your league's conferences for the current dynasty year. Conferences themselves stay fixed; only school membership changes, and only for your league
- webA banner on the League page prompts the commish to review conference alignment during the preseason/offseason
- webNew "Conference Record" card on the team stats page — season and career win-loss record against conference opponents, plus a "Conference History" list showing the record compiled in any prior conference. Career totals are scoped per conference, so moving conferences starts a fresh career tally in the new one without touching the old one
- webRecord Book — conference record chasers now reflect a school's current (realigned) conference instead of its static default; records already set in a school's previous conference are untouched and stay attributed there
- dbNew
ConferenceAssignment table — sparse per-league, per-dynasty-year override of a school's conference, falling back to the static default when unset
Web — track a second league
- webWeb users can now create and track a second (or third+) league from the dashboard. A "+ Add another league" link in the sidebar and a matching "Create a new league" card on the league picker (
/select-league) route to the existing create-team wizard with a ?new=true flag that bypasses the "you already have a league" redirect. Switching between leagues already worked (cookie-based active league + league picker) — this just unlocks reaching the wizard a second time
Stats — manual entry team name fix, roster pre-population
- web
POST /api/stats/manual — team name is now always derived server-side from the submitter's assigned roster team instead of trusting client input. A free-typed value like "App State" (vs. the canonical "Appalachian State") matched neither the school name nor abbreviation, silently excluding those stats from Record Watch even though they showed up fine in League Records
- webManual stat entry — the "Your team" field is now a read-only display once your team is known (falls back to an editable field only if a member has no team assigned yet)
- webManual stat entry — Passing/Rushing/Receiving/Defense tabs now pre-populate rows for players who already have a stat logged this season in that category, instead of starting from a single blank row every time
- dbOne-off data correction: 3 existing
GameStat rows with teamName: "App State" updated to the canonical "Appalachian State" so previously-submitted stats now show up in Record Watch
Schedule — solo-league fixes, active week indicator, cancel a game
- webSchedule page — the "vs. User" / "vs. CPU" toggle is now hidden entirely for leagues with no other members (solo web leagues), so the Set Schedule form defaults straight to the CPU team picker instead of showing an unusable "vs. User" option with nobody to pick
- webSchedule page — Set Schedule and Edit Opponent now update the row immediately on save instead of waiting on a full page refresh to reflect the change
- webSchedule page — Preseason (Week 1) no longer appears as a row, since it never has game data. A persistent active-week label now shows next to the page title year-round (including every offseason phase — Portal, Signing Day, Training Results, etc.) so you always know where you are even on weeks with no schedule row
- webSchedule page — added a "Delete" action on any scheduled game that hasn't had a result or stats logged yet, for fixing a mistaken entry or a week that should've been a bye. Blocked once a result, forced-win, self-reported completion, or any stats are attached to the game
- webSchedule page — added a "?" help icon next to the Schedule heading with quick tips: leave bye weeks blank, use the FCS (generic) opponent for placeholder FCS games, use Delete to undo a mistake, and how CPU vs. UvU result confirmation differs
- web
DELETE /api/matchup/[id] — new endpoint backing the cancel action
Schedule — FCS placeholder opponent
- webSchedule page — "vs. CPU" opponent picker (both the Set Schedule form and the Edit Opponent form) now includes an "FCS (generic)" placeholder option, for the games where CFB 25 subs in a generic FCS team instead of a real school. Renders with its own neutral badge instead of the unrecognized-team fallback styling
Stats — Dashboard "Add Stats" shortcut
- webDashboard schedule — each game row now shows an "Add Stats" link under the week label when a matchup exists but no stats have been submitted yet. Clicking it opens manual entry with the correct week pre-selected, keeping score logging and stat entry in one place
Stats — Manual entry improvements + mid-dynasty setup
- webManual stats entry — roster autocomplete: player names are now pre-populated from the active roster, filtered by position group per tab (QB for Passing, RB/FB/HB for Rushing, WR/TE/RB for Receiving, defensive positions for Defense). Free-text still allowed for players not on roster
- webManual stats entry — optional box score section: away/home team, score, and rank fields. When submitted, auto-syncs a confirmed
GameResult and marks the associated Matchup as complete, so the result feeds into the standings without a separate step
- web
/stats now redirects to /stats/players — the old landing page with an embedded upload widget is replaced by Import Stats and Enter Manually buttons directly on the Player Stats page header
- webSidebar Stats section now lists "Import Stats" and "Enter Manually" as explicit sub-items alongside Team Stats and Player Stats
- web
/api/weeks — added weekNumber: { lte: 22 } guard to exclude offseason/placeholder weeks from the stats week picker
- bot
/dd setup — new starting_year option (integer, 2020–2060). Sets league.dynastyYear so leagues joining mid-dynasty don't have to manually advance through years. Defaults to the existing year on re-run (same safe-re-run behaviour as starting_week)
- bot
/dd setup confirmation message now always shows Year XXXX, Week Label so the commish can verify year and week at a glance
- docsREADME — added "Starting Point Scenarios" section covering: fresh league (defaults), mid-season setup, mid-dynasty setup, and solo user joining a league already in progress
Awards — Trophy Case
- dbNew
Award model — stores season awards per league per dynasty year. Fields: category (individual / all_american / all_conference), awardType (named award e.g. heisman), tier (first / second / freshman), conference, playerName, playerPos, teamAbbr, teamName, submittedBy
- web
/awards — Trophy Case view page. Three sections: Individual Awards (14 named awards table), All-Americans (1st/2nd/Freshman tier tabs with position-ordered grid), All-Conference (conference chips + tier tabs). Year selector tabs for historical browsing; defaults to league.dynastyYear. Your team's winners are highlighted per section
- web
/awards/submit — user entry form. Pick category → pick award/tier → pick position → select from your roster dropdown (falls back to free-text if player isn't on roster). Year is auto-filled from the active dynasty year
- web
/awards/manage (commish only) — same form with full alphabetical team picker. Live table of current-season awards grouped by award type, with per-row delete
- web
GET /api/awards — returns awards for the league filtered by year (defaults to league.dynastyYear)
- web
POST /api/awards — commish can set any team; users are server-locked to their own team. Year is always server-set from league.dynastyYear
- web
DELETE /api/awards/[id] — allowed for the commish or the original submitter
- webRoster page — award badges appear inline next to player names: 🏆 for individual award winners, ⭐ (yellow) for All-Americans, ⭐ (purple) for All-Conference. Matched by player name; multiple badges shown if a player won multiple awards. Tooltips show the award name on hover
- webSidebar — "Awards" nav item (Medal icon) with Trophy Case / Submit My Awards / Manage Awards sub-items. Gated behind
awards feature flag (enabled)
Records — contributor badge + scope normalization
- webContributor badge (🏆 amber trophy icon) on the Standings and League pages for users who have had a global record approved. Previously broke for non-Discord users because the badge lookup went through
Member.discordUserId rather than the Account OAuth relation; fixed to query the Account table and match on userId so magic-link and Google OAuth users earn the badge correctly
- web
POST /api/records/ocr — added "PAC12" to the CONFERENCE_ALIASES lookup table. Previously, OCR reads of "PAC12" (no dash) were not normalized and created a duplicate conference scope alongside the existing "PAC-12" records. Existing duplicate records in the database were migrated to the canonical "Pac-12" value
Non-Discord users — solo team management
- db
Member.discordUserId is now nullable — web-created members are identified by userId (NextAuth User ID) rather than a Discord snowflake
- db
Member.userId new field — FK to the NextAuth User table. New @@unique([leagueId, userId]) constraint for web members
- db
League.guildId is now nullable — solo leagues have no Discord guild ID
- db
League.createdVia new field — "discord" (default, all existing leagues) or "solo" (web-created)
- webLogin page now offers three sign-in options: email magic link (password-free), Google OAuth, and Discord OAuth — all on one page
- webNew
/create-team page — two-step wizard to pick an FBS school and name a league, then creates a solo league (no Discord required). Accessible from the "no league" state after login
- webAdvance Week button — commishes of solo leagues can advance the week directly from the dashboard. Full season rollover (player class progression, scouting board advances, SR graduation) runs identically to the Discord bot's advance path. Disabled with tooltip for Discord-managed leagues
- webOnboarding checklist card — shown to new solo users on the dashboard: Team created, Add first player, Upload first game stat. Dismissible; auto-hides when all steps complete
- web
POST /api/leagues/[leagueId]/advance — new API route for web-triggered advance. Solo leagues only; commish session required
- webAll auth guards updated from
discordId-required to user.id-required (NextAuth always populates this). Member lookups across all routes now use an OR clause: userId OR discordUserId
Records — My Record Watch page
- web
/my-records — new "Records" entry in the My Team sidebar section. Shows your team's active players chasing school, conference, and national records on a single page
- webSeason tab displays a progress bar and on-pace season projection for your best player in each stat category, across all three scopes simultaneously. A ★ badge and "record broken!" label appear when a player has already surpassed the current mark
- webGame and Career tabs show the current record holders for reference without live progress columns
- webIf your school has no records imported yet, the School section is replaced by a CTA card linking to the Contribute Records page
- webMembers with no team assigned see a gate message prompting their commissioner to set a team
Records — User-contributed school records
- db
RecordSubmission model — stores user-submitted school record screenshots through an approval queue before entering the global record book. Fields: school (abbr), level, status (pending/approved/rejected), Discord submitter identity, rows (JSON), and optional adminNotes
- web
/records/submit — user-facing upload page for contributing school record screenshots. Scope is pre-locked to the user's assigned team; reuses the same OCR pipeline and editable review table as the admin import flow. Shows a warning if OCR detects a different school than expected. Users without a team assignment see a gate message
- web
SchoolRecordsBanner — shown on the Record Book page when a user navigates to their own school's scope. Displays per-level status (imported / pending / rejected) and a "Contribute records" CTA for any missing levels. Rejected submissions show the admin's note and allow re-submission
- web
/admin/records/submissions — admin queue page with Pending / Approved / Rejected tabs, sorted oldest-first. Each submission shows an inline editable review table (admin can correct OCR before approving), an "will overwrite existing" badge when global records already exist for that school+level, and Approve / Reject with note actions
- webAdmin nav gains a "Submissions Queue" entry (Inbox icon) with a live pending-count badge
- webSidebar nav gains "Contribute School Records" sub-item under Records (visible to all users when the records feature flag is enabled)
- web
GET /api/records/school-status — checks which levels have global records for a school and returns the requesting user's own pending/rejected submissions
- web
POST /api/records/submissions — validates school ownership against member.teamId, enforces one pending submission per school+level globally (409 on duplicate), creates RecordSubmission
- web
POST /api/admin/records/submissions/[id]/approve — approves with optional row edits; calls the shared importRecordRows() helper (same idempotent delete+reinsert path as the admin import endpoint)
- web
POST /api/admin/records/submissions/[id]/reject — stores rejection note, sets status to rejected, allows the user to re-submit
- web
importRecordRows() extracted to lib/records.ts — shared by the admin import route and the approval endpoint; eliminates duplication
- web
ResultCard extracted to components/records/ResultCard.tsx — shared by the admin import page, admin submissions queue, and user submit page. Accepts readonlyScope prop (locks scope display) and scopeMismatch prop (shows warning when OCR detected a different school)
Fixed — Season and Game record OCR
- ocrSeason and Game screenshots now parse all 9 values correctly. Previously, when the level tab text was unreadable, the parser defaulted to Career column layout and the year+value were concatenated into a single number (e.g. "2020623" instead of year=2020, value=623). Small values (1–2 digits) were also silently dropped when read from a narrow single-column crop.
- ocrYear and stat value are now read as one combined region (same wide crop as the career stat column, extended right). Post-processing splits the result at character 4: the year always occupies the first four digits, the stat value follows. A single PSM 6 read on 5–9 digits of content is far more reliable than two separate narrow crops whose content varies from 1 to 5 digits.
- ocrAutomatic Season vs Career detection: when the level tab text is unreadable, three probe reads on the year-column position determine the layout. If ≥2 of the first 3 rows contain a 4-digit value in the range 1950–2030, the screenshot is treated as Season/Game (career stat totals are always outside that range). The resolved level is returned in the API response so the review card pre-selects the correct tab.
Admin — Global Records import via screenshot
- webAdmin Import Records page (
/admin/records/import) — OCR-based bulk import of the global record baseline: select Season/Game/Career level, drop a CFB Records screenshot, review 9 parsed rows with editable fields and low-confidence warnings, then confirm to overwrite that scope+level globally
- webAdmin sidebar gains "Import Records" nav link; Global Records page gains "Import screenshot →" shortcut button
- web
POST /api/admin/records/import — app-admin-gated endpoint; idempotent delete+reinsert for the submitted scope+level, validated against known categories
Fixed
- dbAdd
cpuOpponentTeam column to Matchup — stores the CPU opponent team abbreviation at schedule-set time, so it's visible before a result is logged
- [web] Schedule — CPU opponent team now saves when setting schedule — selecting a team from the CPU dropdown and clicking "Set Schedule" previously silently dropped the selection; it's now sent to the API and stored on the matchup
- [web] Schedule — existing bot-created sim matchups now show "Set Opponent" / "Edit Opponent" — previously these rows had no interactive path to attach a CPU opponent team; they now expand an inline team picker that PATCHes the matchup. New
PATCH /api/matchup/[id] endpoint added
- [web] Schedule — Discord emoji stripped from member display names — platform usernames containing Discord custom emoji syntax (
<:Name:id>) now render as plain text instead of leaking raw markup into opponent sub-labels
Records — global architecture, admin area, and redesigned navigation
- db
Record.leagueId is now nullable — null = global baseline shared across all leagues; league-specific overrides win via mergeRecords() helper
- db
FeatureFlag model added — DB-backed feature flags replacing env-var allowlists; isFeatureEnabled("records") is now the single gate
- web
/admin/* area — gated by APP_ADMIN_DISCORD_IDS env var (distinct from commish role); sidebar shows "App Admin" section when active
- webAdmin Global Records page (
/admin/records) — CRUD for global baseline records (leagueId=null), inline row editing, scope navigation
- webAdmin Feature Flags page (
/admin/feature-flags) — toggle switches for all known flags; creates DB row on first toggle
- webRecord book redesign — game-mirrored layout: Level tabs (Season / Game / Career) + 3-row scope navigation: National pill · Conference pills row · School dropdown row (defaults to user's own school)
- webCommish "Manage Records" relabelled to "League Records"; import copy clarified as dynasty override only
- dbSeed script updated:
leagueId: null for all 108 global records; run with node packages/db/scripts/seed-records.js [--force]
Records module (feature-flagged — commish only via FEATURE_RECORDS_ALLOWLIST)
- dbAdd
Record model to schema: scope (national/conference/school), level (season/game/career), category (9 fixed types), holder name/school/year/value
- webRecord Book page (
/records) — tabbed by stat group (Passing/Rushing/Receiving/Defense), shows record holders with best-chaser progress bars and on-pace projections
- webManage Records page (
/records/manage) — commish CRUD: inline edit/delete, scope filters, add form with conditional conference/school fields
- webImport Records page (
/records/import) — OCR-based bulk import: select Season/Game/Career, drop a CFB Records screenshot, review 9 parsed rows, confirm to upsert
- webDashboard widget — "Record Watch" card shows logged-in user's players chasing records with progress bars (≥30% threshold), linked to full record book
- webManual game stat entry form (
/stats/manual) — tabbed Passing/Rushing/Receiving/Defense form, dynamic player rows, submits directly to DB without OCR
- webSidebar nav: Records section with Record Book / Import / Manage sub-links (Import + Manage are commish-only)
- webFeature flag:
FEATURE_RECORDS_ALLOWLIST env var (comma-separated Discord IDs); gates all Records pages, nav items, and dashboard widget
- ocrNew
recordBook template — detects CFB Records screen, extracts scope label + 9 fixed rows × 5 columns; exports parseRecordBook from OCR package
- dbSeed script:
packages/db/scripts/seed-records.js — pre-populates National, SEC, Big Ten, and Alabama school records from real NCAA history; run with DATABASE_URL=... node packages/db/scripts/seed-records.js [leagueId]
Fixed
- [web] Matchups — upcoming weeks with queued games now visible — the week picker was filtered to
current and complete weeks only, hiding any upcoming week that a commish had already queued matchups into via /matchup set. The query now also includes any week that has at least one matchup, so pre-set games appear immediately. Week buttons also now show the friendly schedule label (e.g. "Week 0") instead of the raw internal week number.
- [web] HS Recruiting — pitch picker dropdown no longer clipped — dropdown was being cut off by the table's overflow container. Now uses
position: fixed anchored via getBoundingClientRect so the full 5×4 grid is always visible regardless of scroll position or parent overflow.
Changed
- [web] HS Recruiting — pitch picker as dropdown — pitch picker is now a compact select-style trigger that shows the chosen pitch icon + name. Clicking opens the 5×4 icon grid as a popover; selecting closes it immediately. No longer takes up a full row when a pitch is already set.
Changed
- [web] HS Recruiting — pitch picker — "Correct Pitch" is now a 5×4 icon grid matching the game's 20 recruiting pitches (College Experience, Team Player, Campus Personality, Gamer, Standard Bearer, Student of the Game, Hometown Hero, Status Seeker, The Clutch, Primetime Player, Coach Connection, Aspirational Goals, To The House, Football Influencer, Clocked In, Star Search, Grassroots, Conference Legend, Sunday Player, Gym Rat). Replaces the old Yes/No boolean toggle. Schema column changed from boolean to string to store the pitch name.
Fixed
- [ocr] Dev trait star icon bleed — shifted devTrait crop x from 0.893 → 0.903 to skip the star icon glyph that was still bleeding into the OCR region and producing garbled reads.
Fixed
- [ocr] Player card preprocessing — inverted (white-on-dark) regions now invert *before*
normalize() so histogram stretching works correctly for dark-text-on-light output. Scale increased 2x → 3x and contrast 0.3 → 0.6 for sharper binarization on the condensed game font. Region coordinates for OVR, name, position, archetype, class, and dev trait all widened/tightened for better coverage.
Added
- [ocr] Players Leaving screen parser — new
playersLeaving.js template scans the Week 23 "Players Leaving" table (name, position, year/redshirt, OVR, reason). Uses readText per-cell to preserve text fields. Classifier detects the screen by its header region; parseImage dispatches to parsePlayersLeaving.
- [web] Portal OCR import —
/api/ocr/portal-out endpoint accepts one or more Players Leaving screenshots and returns parsed rows. The Transfer Portal "My Players" tab now has an "Import from screenshot" button that opens a two-step modal: upload → review table with inline editing, checkboxes to exclude rows, and graduation entries greyed out automatically. Confirmed rows are posted to /api/portal (direction="out") and appear in the list immediately.
Added
- [web] Recruiting module — new "Recruiting" section under My Team sidebar with three sub-sections:
- HS Board (
/recruiting/hs) — track high school recruits with star rating, grade (Gem/Normal/Bust), position, archetype, dev trait, class, OVR rank, state rank, correct pitch, and status (Targeting → Offered → Committed → Signed → Decommitted). Status changes are immediate; all other fields are inline-editable via expandable rows. Signed recruits surface an "Add to Roster" button once Week 29 (post-NSD) is reached.
- Transfer Portal (
/recruiting/portal) — tabbed Incoming / My Players view. Incoming players track fromSchool and status (Available → Committed → Signed Elsewhere → Withdrawn). Outgoing (your own) players track portal outcome (In Portal → Returned / Signed Elsewhere / Unsigned).
- Scouting Board (
/recruiting/scouting) — long-lived big board for recruits who signed elsewhere but may enter the portal in future seasons. Class (FR→SO→JR→SR) advances automatically each season rollover; SR entries graduate (deactivated). "Move to Transfer Portal" promotes a player directly to the incoming portal list.
- [db]
Recruit, ScoutingEntry, PortalEntry models — three new tables pushed to Railway covering all recruiting data. Recruit and PortalEntry are scoped to a dynasty year; ScoutingEntry is year-agnostic and updates in place via class progression.
- [bot] Scouting board class progression — season rollover in
scheduler.js now advances currentClass on all active ScoutingEntry records (FR→SO→JR→SR) and deactivates graduated SR players.
- [web] Global
input-cell / select-cell CSS utilities — reusable form-field classes added to globals.css.
Added
- [web] Neutral site location — "Neutral" option added alongside Home / Away / Unknown in the Set Schedule and Log Result forms. Both UvU players see a yellow "N" badge on their schedule row.
neutralSite stored as a boolean on Matchup.
- [db]
Matchup.neutralSite — Boolean @default(false) column pushed to Railway.
- [bot]
/result log → thread post — after logging a result, the bot now posts a summary message to the matchup thread (if one exists). CPU games post immediately on confirmation; UvU games post "pending confirmation" with the opponent @-mentioned.
- [web] Favicon — replaced the default Next.js favicon with the DynastyDesk logo via
app/icon.png.
Added
- [web] Set Schedule — empty-week rows now show a "+" placeholder with a "Set Schedule" inline form. Users can pick UvU vs. CPU, select the opponent from a dropdown, and mark themselves Home or Away. For past weeks, a "Log Result" checkbox lets you submit scores in the same step.
- [web] Home/Away tracking — new
homeUserId field on Matchup (DB migration applied). Schedule rows display an H or @ badge. The Log Result form also exposes a Home / Away / Unknown selector that updates the matchup record.
- [db]
Matchup.homeUserId — nullable Discord user ID indicating which player is playing at home.
Changed
- [web] Team badge colors — all 138 teams now use real primary brand hex colors (e.g. Ohio State scarlet, LSU purple, Tennessee orange) instead of hash-derived hues. Sac State green badge; NDSU dark green badge.
- [web] NDSU + Sacramento State enabled — both teams promoted from
future_2026 to current status.
- [web] Nav order — sidebar sections reordered to My Team → League → League Ops.
- [web] Dashboard header — page title changed from "Week X" to "{year} Schedule" (e.g. "2026 Schedule"). The active week is already visually indicated by the NOW badge on the schedule itself.
- [web] Preseason hidden during season — the Preseason row group is suppressed once any regular-season week has started.
- [web] Log Result form — includes Home / Away / Unknown selector; updates
homeUserId on the matchup when saving.
Changed
- [web] Sidebar nav restructured — new top-level "League" section (Matchups, Standings) visible to all users. "My Team" section now contains only personal items (Dashboard, Roster, Stats). Commish-only items (League admin, Activity) moved to a "League Ops" section at the bottom.
- [web] Dashboard layout — weekly matchups panel removed from the commish dashboard; schedule now occupies the full 2/3 column for both commish and regular users. Matchups are accessible via the League nav.
- [web] Schedule redesign — each week is now a distinct card with a 48×48 team badge (deterministic per-team color derived from abbreviation), large monospace score, and a prominent colored W/L badge. School name shown as the primary opponent label; player name as subtitle. Ranked matchups display inline below the opponent.
- [web] Typography —
--muted-foreground bumped from L=0.75 to L=0.82 for better readability of secondary text on dark backgrounds.
Fixed
- [web] Commish dashboard missing schedule — commishes saw an early-return view with only the current-week matchup list and no personal schedule. Schedule data is now fetched before the commish/user split, and
ScheduleTable is rendered at the bottom of the commish dashboard too.
Changed
- [web] Stats sub-nav in sidebar — the Stats nav item now expands to show "Team Stats" and "Player Stats" as indented sub-links whenever any
/stats/* route is active. Direct access without going through the hub page.
Changed
- [ocr] Image cache per OCR session — each uploaded image is now decoded from disk once per submission and reused for all region crops. Previously
Jimp.read was called once per region (~50–70× per image), making full 4K PNG decoding the dominant CPU cost per request.
- [ocr] Persistent Tesseract worker — the worker now stays alive between requests and auto-terminates after 5 minutes of inactivity. Previously it was torn down after every OCR request, paying the full language-model cold-start cost on each submission.
Added
- [db]
OcrCorrection model — stores per-field diffs between raw Tesseract output and user-saved values on every stat submission. Captures screen type, field name, OCR value, corrected value, and confidence score.
- [web] OCR correction logging — raw OCR output is now snapshotted before the review/edit step and diffed against the final saved values. Corrections are written non-fatally after each submission as groundwork for future OCR accuracy analysis and pattern detection.
Added
Schedule System
- [web] Season schedule on dashboard — user dashboard now shows a full season schedule (weeks 1–22) replacing the single matchup card. Preseason and Regular Season are expanded by default; Postseason (weeks 18–22) is collapsed until a user has activity there. Offseason weeks (23–30) are hidden entirely.
- [web] Year selector — schedule header includes a season dropdown when multiple dynasty years exist, allowing users to browse past seasons read-only.
- [bot]
/result log — new slash command to record post-game results. Options: score, opponent_score (required), plus optional opponent_team, my_rank, opponent_rank, week. CPU/sim games auto-confirm; UvU games send a Discord DM to the opponent.
- [bot]
/result view — shows the logged result for the current (or specified) week.
- [bot] UvU result confirmation — when a player logs a UvU result, the opposing player receives a Discord DM with ✅ Confirm / ❌ Dispute buttons. Confirming marks the matchup complete; disputing alerts the commish.
- [web] Inline result logging — past weeks with no result show a "Log Result" button that expands an inline form. UvU games prompt the opponent to confirm or dispute directly in the schedule table.
- [web]
/api/result POST — creates or updates a GameResult record. CPU games confirm immediately; UvU games go pending and trigger a bot DM via the scheduler polling loop.
- [web]
/api/result/[matchupId] PUT — confirm or dispute a pending result. Validates caller is the opposing player.
Score & Result Tracking
- [db]
GameResult model — stores userScore, opponentScore, opponentTeam, myRank, opponentRank, result (W/L/T), status (pending/confirmed/disputed), submittedBy, confirmedBy, and notificationSent flag for bot DM dispatch.
- [bot]
/games score display — matchup embed now shows confirmed results (e.g. W 42–17 (#8 vs #15)) and pending confirmation state (🕐 Awaiting confirmation).
- [bot] Status board scores — completed UvU games on the status board now show the score inline (e.g.
✅ 42–17).
- [bot/web] OCR → GameResult sync — submitting box score screenshots (web Submit page or bot
/stats submit) automatically creates a confirmed GameResult record. Manual /result log entries are skipped if a confirmed OCR result already exists.
- [web] Standings fallback —
computeStandings() now falls back to confirmed GameResult records when no BoxScore exists for a completed matchup.
Multi-Season Support
- [db]
Week.dynastyYear — weeks are now scoped to a dynasty year. Unique constraint changed from (leagueId, weekNumber) to (leagueId, weekNumber, dynastyYear), fixing a latent bug that would have broken week creation in season 2.
Changed
- [bot] Thread creation message — references updated from
/matchup complete to /result log.
- [bot] Game reminder DM — updated to direct players to use
/result log to record their score after the game, then react ✅ to ready up.
- [bot]
/help — replaced /matchup complete with /result log and /result view in the quick reference.
Fixed
- [web] Position dropdown — updated to game-specific positions: QB, HB, FB, WR, TE, T, G, C, REDG, LEDG, DT, SAM, WILL, MIKE, CB, FS, SS, K, P. Previous list used generic labels (RB, OL, DL, LB, DB) that didn't match in-game values.
- [web] Archetype field — changed from free-text input to a position-dependent dropdown. Valid archetypes populate automatically when a position is selected; changing position clears archetype if the current value is no longer valid. Locked with placeholder text until a position is set.
- [web] Dev trait dropdown — corrected values to Normal, Impact, Star, Elite.
- [web] OVR input — replaced
<input type="number"> (which showed unwanted spinner arrows) with <input type="text" inputMode="numeric">. Accepts up to 2 digits; non-numeric input is stripped.
- [web] Roster table empty after upload — added
router.refresh() on successful OCR import so the player table repopulates immediately without a manual page refresh.
- [ocr] Position normalization — OCR import now maps game-native position codes to canonical values before saving: LT/RT→T, LG/RG→G, RE→REDG, LE→LEDG, MLB→MIKE, LOLB→WILL, ROLB→SAM. Previously only QB and TE imported correctly; all other positions stored the raw game value.
Added
Web Dashboard
- [web] Role-aware dashboard — commishes see all matchups, ready counts, and recent activity; regular users see their personal matchup card, last game box score + leaders, and enrichment status.
- [web] Roster page — each user's player table with inline editing for pos, OVR, archetype, dev trait, year, and redshirt flag. Incomplete players (missing pos or OVR) sorted to top with a yellow indicator.
- [web] Roster screenshot upload — drag-and-drop modal on the Roster page accepts PS5 4K screenshots and imports up to 10 players per image via OCR. Multiple screenshots can be uploaded in one batch; duplicates are deduplicated by name.
- [web] Stats submit drag-and-drop — the Submit page now accepts screenshots via drag-and-drop in addition to the file picker.
- [web] Role-split sidebar — League Ops section (Matchups, League, Activity) is visible to commishes only; My Team section (Dashboard, Roster, Standings, Stats, Submit) is shown to all members.
- [web] League Ops → League page — lists all active league members (renamed from "Roster").
Player System
- [db] PlayerSeason model —
Player is now identity-only (name, jersey, active). All season-specific data (pos, OVR, archetype, devTrait, year, redshirt, isRedshirtYear) lives in PlayerSeason keyed by (playerId, dynastyYear).
- [db]
League.dynastyYear — tracks the current in-game season year (default 2026). Increments automatically on season rollover.
- [web] Player enrichment — stats submission (web + bot) creates
PlayerSeason stubs for the current dynasty year; users fill in pos/OVR/archetype/devTrait on the Roster page.
- [web] Redshirt tracking — users can toggle "RS?" on any player mid-season via the Roster page. At season rollover, redshirted players stay at their current class; the
isRedshirtYear flag clears and redshirt is permanently marked.
OCR — Roster Screen
- [ocr] Roster template — new OCR template for the in-game Roster screen. Calibrated against 3840×2160 PS5 screenshots:
startY=0.430, rowHeight=0.045, stripH=0.018. Reads name, year (including RS suffix), position, and OVR for up to 15 rows per screenshot.
- [ocr] Roster classification — classifier updated to detect "ROSTERS" header at
{ x:0.040, y:0.020, w:0.200, h:0.050 } and route to parseRoster().
- [ocr]
parseYear resilience — handles common Tesseract misreads: 5→S, 0→O, RY→RS. Correctly parses "50 RY" as SO with redshirt.
Bot
- [bot] Season rollover logic —
performAdvance() now detects week-30 advance and runs class progression: FR→SO→JR→SR, RS-flagged players stay same class, SR players without RS flag are marked inactive (graduated). OVR clears for the new season; pos/archetype/devTrait carry forward.
Changed
- [web] Navigation renamed — "My Players" → "Roster"; old "Roster" (league members) → "League".
- [web] Font — base font size bumped to 18px. Section labels in the sidebar are more prominent with accent color + gradient rule.
- [web] Sidebar wider —
w-60 → w-64.
Schema changes
All schema changes were applied to Railway via db:push during development. No additional migration needed on deploy.
- Added
League.dynastyYear Int @default(2026)
- Added
PlayerSeason model (see above)
- Removed flat
Player.position, Player.archetype, Player.devTrait, Player.ovr — replaced by PlayerSeason
Deploy notes
- No
npm run deploy needed — no new slash commands in this release.
git push → Railway auto-deploys both the web service (Dockerfile) and bot service.
Fixed
/advance start FW outcomes — FWs recorded by the commish via /matchup fw (which sets status: complete) were previously invisible in the advance wizard. The response now includes a "FW outcomes recorded this week" section listing all completed FW games alongside the existing pending-FW checklist. Same fix applied to /preview.
Added
/golive command — members run this when they're about to stream their game. The bot posts a rich embed to a dedicated stream alert channel with the member's team name, stream link(s) (Twitch and/or YouTube — both shown if both are configured), current week label, and matchup details (opponent team name + mention for UvU games, "Simulation game" for CPU matchups). Fails gracefully with actionable ephemeral messages if stream alerts aren't configured, the member has no stream link, or the alert channel has been deleted.
/dd streams channel [#channel] (commish) — sets an existing channel as the stream alert destination, or auto-creates #stream-alerts when no channel is provided. Enables stream alerts in one step.
/dd streams enable (commish) — re-enables stream alerts after they've been disabled. Errors clearly if no alert channel has been configured yet.
/dd streams disable (commish) — pauses stream alerts without clearing the channel configuration, so re-enabling is instant.
Schema changes (run db:push against Railway after deploy)
League.streamAlertChannelId (String?) — stores the Discord channel ID for go-live announcements
League.streamAlertsEnabled (Boolean, default false) — feature toggle; preserves channel config when disabled
Changed
- UvU and CPU game thread opening messages — both thread types now include
/golive in their commands list so members are reminded to announce their stream when they're ready to play.
Note
This release adds a new command and a new subcommand group — npm run deploy must be run after deploy to register /golive and /dd streams with Discord.
Fixed
- Thread names — matchup threads now use the member's Discord display name when no team is assigned, instead of the opaque
User #### placeholder. The old fallback is retained if the user can no longer be fetched (e.g. they left the server).
- FW DM notifications — commish DMs now show display names instead of raw
<@id> mention syntax. Mobile push notification previews are now human-readable.
/advance start FW checklist — the FW pending section now shows exactly who to grant the win to (grant FW to @winner) and is labelled as a checklist rather than a generic warning.
Changed
- OCR preprocessing — cropped regions are now 2× upscaled, converted to greyscale, histogram-normalised, and contrast-boosted before recognition. Improves accuracy across all stat screens without requiring higher-quality screenshots.
- Tesseract page segmentation — switched from default PSM 3 (full page analysis) to PSM 7 (single text line). More appropriate for stat cell crops; reduces misclassification and slightly lowers CPU cost.
- Numeric field cleanup — all stat columns (everything except player and team name) now go through a substitution pass (
O→0, l/I→1, S→5, B→8, G→6, Z→2) followed by non-numeric character stripping. Fixes common OCR misreads that passed the text whitelist.
/stats submit low-confidence detail — the confirmation embed now shows the specific player and flagged field values for every low-confidence row (e.g. ↳ ⚠️ J. SMITH — comp: "l2", yards: "I87"), so users can judge whether to confirm or discard before reacting.
/stats submit confirmation messaging — footer and discard message are now contextual: when warnings are present, the footer advises confirming if values look right; the discard message no longer suggests re-uploading the same screenshots will produce a different result.
Fixed
- Team name leading-digit strip — the L2 button prompt that appears near the team name region was already stripped in defense and blocking templates but not in passing, rushing, or receiving. Now applied consistently across all five stat templates.
Added
/roster retire @user (commish) — marks a member as retired when they leave the league. Retired members are immediately removed from the status board and no longer counted in the ready/not-ready tally. Historical matchup and stat data is preserved.
/roster unretire @user (commish) — restores a retired member to the active roster in case of a mistake or return.
/roster view — retired section — the roster view now shows a 🚫 Retired section below the active roster so commishes can see who has been retired.
Fixed
- Stream link removal — passing
clear, remove, -, or none as the twitch or youtube value in /roster add or /roster set now wipes the stored URL. Previously there was no way to remove a stream link once set.
Schema changes (run db:push against Railway after deploy)
Member.retired (Boolean, default false) — tracks whether a member has left the league
Note
/roster gained two new subcommands — npm run deploy must be run after deploy to register them with Discord.
Changed
/stats submit — week selection — new optional week integer option lets users submit stats for any week that has already started. Validates against existing current/complete weeks and replies with the valid range if the number is invalid. Defaults to the current week when omitted.
/stats submit — duplicate guard — before confirming, the bot checks whether stats already exist for that user + week. If a prior submission exists, the confirmation embed turns orange and the footer shows an overwrite warning. Reacting ✅ deletes all prior records in a transaction and writes fresh data; ❌ cancels with no changes.
- UvU and CPU game thread opening messages — both thread types now include
/stats submit in their commands list so players know how to log stats without leaving the thread.
Note
/stats submit gained a new option — npm run deploy must be run after deploy to register the updated command with Discord.
Added
/stats submit command — upload up to 7 post-game screenshots; the bot automatically parses all stat screens (box score, passing, rushing, receiving, blocking, defense) via Tesseract OCR, shows a confirmation embed with player/team counts and low-confidence warnings, and saves to the database on ✅ reaction.
- OCR engine — fully calibrated for PS5 4K after-game screenshots. Classifier detects screen type (passing/rushing/receiving/blocking/defense1/defense2/boxScore) and layout (afterGame). Column coordinates tuned against real screenshots for all stat categories.
- Defense two-screen merge — defense stats are split across two game screens; the bot accepts both screenshots and merges them by row position automatically.
Changed
- Defense schema field — renamed
defl to pdef to match the parsed column name (passes defended).
- Classifier robustness — added column-header fallback detection for blocking and defense screens that don't expose a stat-type label in the standard region.
- Defense merger — changed from hard throw on row count mismatch to a graceful trim with warning log; handles the noise rows in D2 that D1's threshold filter removes.
Schema changes (run db:push against Railway after deploy)
- New models:
GameStat, BoxScore, PassingStat, RushingStat, ReceivingStat, BlockingStat, DefenseStat
DefenseStat.pdef replaces the old defl field
Note
This is a new command — npm run deploy must be run after deploy to register /stats submit with Discord.
Fixed
/matchup set future-week bug — the week option previously accepted a raw integer that mapped to the internal database week number, not the display label. Entering 7 while in "Week 5" resolved to internal week 7 = display "Week 5" = the *current* week, so the matchup was treated as live, a thread was created immediately with the wrong week label, and the game appeared on the current board. All three week options (set, view, clear) now use the same autocomplete dropdown as /dd setup and /week set, presenting display labels ("Week 5", "Week 6"…) that resolve to the correct internal values.
Changed
/matchup view and /matchup clear error/response messages now show display week labels ("Week 7") instead of raw internal numbers ("Week 9").
/matchup set default description updated to clarify that omitting week defaults to *next* week (not current week).
Note
This release changes slash command option types — npm run deploy must be run after deploy to re-register commands with Discord and activate the autocomplete dropdowns.
Changed
- Games section now shows User vs User matchups only — CPU/sim games are no longer listed on the status board. Anyone not in the games list is assumed to have a CPU game or bye week.
- Section renamed from "Games This Week" to "User vs User Games This Week".
- Thread links removed from board — game lines no longer include the
→ #thread channel link. Commissioners are added directly to threads (see below), making the link redundant.
Fixed
- Reactions broken on split boards — leagues large enough to trigger the v0.1.9 multi-message split had completely non-functional ✅/🏝️ reactions. The reaction handlers were doing a direct string equality check against
statusMessageId, which fails when it's stored as a JSON array. Both handlers now parse the array and check against the first message ID.
Added
- Commissioners auto-added to UvU threads — when a User vs User matchup thread is created (via
/matchup set or /advance confirm), all league commissioners are automatically added as thread members. This gives commishes visibility and admin access to every game thread without needing a link on the board.
Added
- Manual advance mode —
/schedule cadence now accepts an advance option (auto or manual). In manual mode the week does not advance automatically when the deadline passes; instead the bot pings all commissioners in the league channel and waits for a commish to run /advance manually. Advance mode is shown in /schedule view.
/dd board — force re-post the status board for the current week (replaces the existing board without advancing the week). Useful when the board is lost or needs a fresh post.
Fixed
- Status board overflow for large leagues — boards with 18+ members and many matchup lines previously crashed with
DiscordAPIError[50035]: Must be 2000 or fewer in length. The board now splits into multiple messages when content exceeds Discord's 2000-character limit. Reactions remain on the first message; updates edit each chunk in place.
- Multiple commissioners can now use all commish-gated commands (
/dd board, /dd reset, /dd channel, etc.) — previously only the first commissioner ID was checked in some paths.
/dd subcommands are now visible to all registered commissioners, not just server admins.
Schema changes (run db:push against Railway)
League.autoAdvance (Boolean, default true) — stores manual vs auto advance preference
Week.manualAdvanceNotifiedAt (DateTime?) — tracks whether the commish ping has already been sent for a missed deadline in manual mode
Fixed
/matchup set no longer creates a thread immediately when the target week is not the active week — thread creation now only fires when the matchup's week number matches league.currentWeek; future-week matchups display "Thread will be created when that week is active" instead
Added
/dd commish add <user> — promote a user to commissioner
/dd commish remove <user> — revoke commissioner permissions (blocked if last commish)
/dd commish list — display all current league commissioners
Changed
/dd commish is now a subcommand group; the promote flow moves from /dd commish <user> to /dd commish add <user>
Added
/mydesk command — personal quick-ref for your team, stream links, and matchup
- Sim/CPU matchup support via
/matchup sim
- Post-game ready reminders sent to UvU threads
Initial release
- League setup, advance scheduling, and status board
- Ready/away reaction tracking
- UvU matchup threads with stream links
/schedule, /week, /roster, /matchup, /fw, /away, /games, /preview, /help
- DST-correct timezone handling via IANA timezones
- Railway + PostgreSQL deployment