Phase 1 — Make the week table real #407

Closed
opened 2026-08-21 17:38:18 +02:00 by johnsturgeon · 0 comments
Owner

Phase 1 — Make the week table real

Parent: #___ · Tech debt: The week table exists but nothing fills it

Why

Week shipped with its schema and no writer. Nothing creates rows, so the
table is empty in every environment and Week.current() cannot answer from it.

To keep the UI off the provider in the meantime, Week.current() returns a
hardcoded WeekInfo (2026 preseason week 2). Every page renders against that
constant. That is safe only while production holds no games — the picks page,
week picker, and standings all come back empty either way.

It stops being safe at regular-season week 1, when a hardcoded week means
players see the wrong week's games or none at all.

Nothing else in this epic can start until the table has rows.

Tasks

  • Move WeekInfo into its own module (app/models/week_info.py) to
    break the week.py / model_helpers cycle. model_helpers.py is gone.
  • Thread a session to the Week.current() call sitesdependency.py,
    admin.py x2, nag_players.py, scheduler.py, update_all_scores.py,
    game.py.
  • Drop active_week and uq_week_active plus a migration. See
    No pointer column below. Confirm the revision drops the column, not
    just the index.
  • Write app/jobs/sync_current_week.py. On an interval, derive the
    current week from the games endpoint and get-or-create the Week row
    for it. Expose it on an admin route so it can be run by hand.
  • Schedule it — on an interval, plus one run at startup before
    schedule_jobs.
  • Delete the hardcoded body of Week.current() and query for real.
    Remove the # pylint: disable=unused-argument with it.

Design decisions

Not from getNFLCurrentInfo. That endpoint is not trusted — see
getNFLCurrentInfo?date= lies about the past in TECH_DEBT. The current week
is derived from game data instead.

No pointer column. Week.current() is the most recent row, ordered by
(season, season_type, week_no) descending. This works because SeasonType
values sort chronologically (1=pre, 2=reg, 3=post — see season.py).

statement = (
    select(Week)
    .order_by(
        col(Week.season).desc(),
        col(Week.season_type).desc(),
        col(Week.week_no).desc(),
    )
    .limit(1)
)

It holds only while rows are created for weeks that have arrived.
Pre-seeding a whole season's calendar would silently break it — worth
remembering during the week_id FK work, which tempts exactly that.

Raises, does not return None. Once the sync has run even once the table
is never empty, so an empty table means it has never run: a bootstrap failure,
not a state. Blank pages would be indistinguishable from the intended
between-seasons view. Raising also keeps the signature -> WeekInfo, so no
caller grows a branch — and it is where the "at least one row" half of the
invariant lives, since Postgres can only express "at most one".

class NoActiveWeekError(RuntimeError):
    """No Week rows exist -- sync_current_week has never run."""

Returns WeekInfo, not a Week row. SQLModel's __eq__ compares every
field, id and timestamps included, so a row loaded from the database never
equals one built from /allpicks query parameters — the week picker's
current-week highlight would silently stop matching. WeekInfo is also
detached-safe (scheduler.py uses the value after its session closes) and
safe to pickle into the APScheduler jobstore until Phase 4 removes that.

When Phase 3 needs week.id, add a second accessor rather than changing what
current() returns.

Bootstrap

There is no seed step — the job is the bootstrap. First run on an empty
table creates one row; every run after finds it or adds the next.

The only gap is between deploy and first run, when Week.current() raises.
Running the job once at startup closes it.

On an empty table there is no prior row to anchor on, so the derivation
cannot be "previous week + 1". It has to resolve from a date. Confirm that
works cold before building the rest of the job around it.

Done when

  • Week.current() contains no hardcoded values and no active_week.
  • The week table has a row in dev, created by the job and not by hand.
  • Dropping every row and restarting produces NoActiveWeekError, not blank pages.
# Phase 1 — Make the `week` table real Parent: #___ · Tech debt: *The `week` table exists but nothing fills it* ## Why `Week` shipped with its schema and no writer. Nothing creates rows, so the table is empty in every environment and `Week.current()` cannot answer from it. To keep the UI off the provider in the meantime, `Week.current()` returns a hardcoded `WeekInfo` (2026 preseason week 2). Every page renders against that constant. That is safe only while production holds no games — the picks page, week picker, and standings all come back empty either way. **It stops being safe at regular-season week 1**, when a hardcoded week means players see the wrong week's games or none at all. Nothing else in this epic can start until the table has rows. ## Tasks - [x] **Move `WeekInfo` into its own module** (`app/models/week_info.py`) to break the `week.py` / `model_helpers` cycle. `model_helpers.py` is gone. - [x] **Thread a session to the `Week.current()` call sites** — `dependency.py`, `admin.py` x2, `nag_players.py`, `scheduler.py`, `update_all_scores.py`, `game.py`. - [x] **Drop `active_week` and `uq_week_active`** plus a migration. See *No pointer column* below. Confirm the revision drops the column, not just the index. - [x] **Write `app/jobs/sync_current_week.py`.** On an interval, derive the current week from the games endpoint and get-or-create the `Week` row for it. Expose it on an admin route so it can be run by hand. - [x] **Schedule it** — on an interval, plus one run at startup before `schedule_jobs`. - [x] **Delete the hardcoded body of `Week.current()`** and query for real. Remove the `# pylint: disable=unused-argument` with it. ## Design decisions **Not from `getNFLCurrentInfo`.** That endpoint is not trusted — see *`getNFLCurrentInfo?date=` lies about the past* in TECH_DEBT. The current week is derived from game data instead. **No pointer column.** `Week.current()` is the most recent row, ordered by `(season, season_type, week_no)` descending. This works because `SeasonType` values sort chronologically (1=pre, 2=reg, 3=post — see `season.py`). statement = ( select(Week) .order_by( col(Week.season).desc(), col(Week.season_type).desc(), col(Week.week_no).desc(), ) .limit(1) ) It holds **only while rows are created for weeks that have arrived.** Pre-seeding a whole season's calendar would silently break it — worth remembering during the `week_id` FK work, which tempts exactly that. **Raises, does not return `None`.** Once the sync has run even once the table is never empty, so an empty table means it has never run: a bootstrap failure, not a state. Blank pages would be indistinguishable from the intended between-seasons view. Raising also keeps the signature `-> WeekInfo`, so no caller grows a branch — and it is where the "at least one row" half of the invariant lives, since Postgres can only express "at most one". class NoActiveWeekError(RuntimeError): """No Week rows exist -- sync_current_week has never run.""" **Returns `WeekInfo`, not a `Week` row.** SQLModel's `__eq__` compares every field, `id` and timestamps included, so a row loaded from the database never equals one built from `/allpicks` query parameters — the week picker's current-week highlight would silently stop matching. `WeekInfo` is also detached-safe (`scheduler.py` uses the value after its session closes) and safe to pickle into the APScheduler jobstore until Phase 4 removes that. When Phase 3 needs `week.id`, add a second accessor rather than changing what `current()` returns. ## Bootstrap There is no seed step — the job *is* the bootstrap. First run on an empty table creates one row; every run after finds it or adds the next. The only gap is between deploy and first run, when `Week.current()` raises. Running the job once at startup closes it. **On an empty table there is no prior row to anchor on**, so the derivation cannot be "previous week + 1". It has to resolve from a date. Confirm that works cold before building the rest of the job around it. ## Done when - `Week.current()` contains no hardcoded values and no `active_week`. - The `week` table has a row in dev, created by the job and not by hand. - Dropping every row and restarting produces `NoActiveWeekError`, not blank pages.
johnsturgeon added reference phase-1-make-the-week-table-real-407 2026-08-22 08:52:48 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
johnsturgeon/tgfp-web#407
No description provided.