Value board (match cards) review — 2026-07-14
Review of the hosted value board partial
(/partials/matches?season=2026&round=Round 20 on
scrapers-ui.doks-ci-cd.hungee.cloud) and its source template
nrl-bet-advisor/web/templates/partials/value_board_rows.html plus the
context builder in nrl-bet-advisor/web/services/dashboard.py.
Access note: the hosted URL returns HTTP 403 to unauthenticated clients —
BasicAuth is active on the cluster, which is correct behaviour (and confirms
the auth secret is mounted). This review was therefore done against the
template and service code that render that exact partial, which is the same
markup the cluster serves.
What the partial is
/partials/matches is an htmx fragment, not a page. The dashboard page
fetches it on load and on season/round change and swaps it into the value
board region. Fetched standalone (as in the URL above) it renders without the
site stylesheet — that is expected, not a defect. Documenting this here so
nobody "fixes" the unstyled standalone view.
What works well
- One card per match with a clear primary recommendation, model probability,
fair-odds pair, edge, and book odds — the decision-relevant numbers are all
on the card. - Flucs sparkline with Open/Now/Move chips per market, switchable between
H2H/LINE/TOTAL via the supporting-market buttons — this surfaces the
open→close movement that our CLA work established as the fastest edge
confirmation signal. - Completed-game cards settle honestly: outcome pill, P&L at flat stake,
odds provenance tooltip (odds_source), "No qualifying bet" state. - Empty/error states go through the shared
partials/state.htmlinclude.
Defects and improvements (backlog, ordered)
P1 — model attribution missing from cards
dashboard.py carries model_id at prediction level (it is set when
predictions are read) and the model-selection logic knows which model priced
each market (logit_h2h, lineup_margin_ridge, total model), but none of it
reaches card.primary. The card says "Model 62%" without saying which
model. For a program whose whole premise is per-model verdicts and promotion
gates, every recommendation must carry its model ID (and ideally link to its
/models card). Fix in the service layer: pass model_id through to
card.primary and each supporting row, render as a small tag next to the
probability. This is the top item because it is a trust/lineage gap, not a
polish gap.
P1 — "GO" badge reads as real-bet advice
The product is paper-trading against promotion gates; nothing has cleared
the gates (docs/executive-brief.md). A green "GO" badge with no
qualifier reads as "place this bet". Quick win applied in this round:
title/aria-label on both badges clarifying "paper-trade signal — no
strategy has cleared promotion gates". Follow-up (service layer): consider
renaming the badge pair to PAPER GO / WATCH, and surface the strategy name
(open_fav_v1) once the strategy filter drives the badge rather than the
raw per-market EV threshold.
P2 — inline script redefined on every htmx swap
The <script> block at the bottom of the fragment re-declares
switchFlucs/_applyFlucs each time htmx swaps the partial in (every
round/season change). Harmless today but it is repeated work, and any future
state kept in those functions would be silently reset. Quick win applied:
idempotency guard (if (!window.switchFlucs)) so the functions are defined
once per page life. Follow-up: move the two functions into
web/static/js and drop the inline block entirely (fragment stays
script-free, CSP-friendlier).
P2 — _applyFlucs rebuilds chips via innerHTML
The chip values come from our own data attributes today, so there is no
live injection path, but building DOM via string concatenation is the
pattern that turns into XSS the day a value becomes user-influenced (e.g.
a bookmaker name in a chip). Follow-up when the script moves to static JS:
build chips with textContent.
P2 — emoji-only market indicators
🟢/🟣/🔵 precede the market names in supporting rows and 🟢/🟡 lead the
GO/WATCH badges. Colour/emoji is the only differentiator for the dot; screen
readers announce the raw emoji. Quick win applied: aria-hidden="true" on
the decorative emoji (the market name text is already adjacent) and
aria-pressed state on the market-switch buttons so toggling is announced.
P3 — Edge stat has no definition on hover
"Edge +4.2%" is EV against the book price at the model's probability, but
the card doesn't say so. Quick win applied: title tooltip on the Edge
stat. Follow-up: link the label to the glossary entry on the /docs page.
P3 — completed-card flucs lack the market switcher
Upcoming cards can toggle H2H/LINE/TOTAL flucs; completed cards render only
the primary market's movement. Minor inconsistency; fix when the script
moves to static JS.
P3 — no round-level summary strip
The board lists cards but never totals them: n picks, n GO, aggregate edge,
and (for completed rounds) round P&L. A one-line strip above the list would
make the partial useful as a standalone health check. Service-layer change.
Quick wins applied in this round (template only)
- Idempotency guard around the inline
<script>(functions attach to
windowonce instead of redefining per swap). aria-pressedon.vb-market-switchbuttons, toggled in JS.title+aria-labelon GO/WATCH badges stating paper-trading
semantics.titletooltip on the Edge stat (both card variants).aria-hidden="true"on decorative market/badge emoji.
Everything else above is service-layer or JS-relocation work — left for a
CLI round because it needs the running app to verify (htmx swap behaviour,
screen-reader output, model_id plumbing against real predictions).