statshub-config
What every board shows, what it starts as, and who may see it — as JSON both apps read.
Two questions, one package:
- Does this exist, and who may see it? —
app.jsonand each feature'sentries. - And what does it look like? — each feature's
filters,tableandcard.
Plain JSON, so an admin screen can change a starting odds range or a default column without a deploy, and so web and native read one file rather than each keeping a list that drifts.
Layout
index.ts imports every file statically. That is not tidiness — Metro cannot
resolve a require() built at run time, so a feature added here has to be named
there too.
defaults.json — what is true until a feature disagrees
| Key | Holds |
|---|---|
pagination | Rows per request, mobile and desktop |
data | SWR dedupe window, poll interval, focus revalidation, retries |
odds | The slider's absolute range, step and decimals |
hitRate | The windows offered (L5/L10/L15) and which is default |
limits | Ceilings — saved views, folders, search results shown, chips in a rail |
brand | Name, domain, social handle, support URL |
compliance | Responsible-gambling marks and the minimum age |
odds here is the slider's floor, not a board's starting point
They differ, and conflating them is a bug that has already shipped: the slider
runs from 1.01, Prop Screener starts at 1.30, and a control comparing its value
against its own minimum read that gap as a filter the reader had applied — so an
untouched board reported "Clear 1". A board's zero point belongs in its own
filters.defaults.
features/<id>.json — one board
{
"entries": [ … ], // subroutes: state, access, platform paths
"filters": {
"defaults": { … }, // the board's ZERO POINT
"important": ["league"], // which also appear on the bar
"groups": [ // panel order, and what is in each group
{ "label": "Leagues", "filters": ["league", "selectedLeagueOnly"] }
]
},
"table": {
"sort": { "column": "L10", "direction": "desc" },
"columns": [ { "id": "player", "label": "Player", "visible": true } ],
"pageSize": { "mobile": 100, "desktop": 250 } // overrides the global pair
},
"card": {
"rows": [["player", "team"], ["market", "line", "odds"]]
}
}Three things the shape encodes:
- Order is array order — for
entries,groups,columnsandcard.rows. Move an item to move it in the UI. There is noorderfield to keep in sync. visible: falseships a column but leaves it off, so the column menu has something to offer. Deleting the entry removes it entirely.card.rowsis the mobile rendering of one row — each array is a line, each id a field on that line, left to right. A field in no row is not shown on mobile at all.- A group's
labelresolves its icon, through the same table the filter controls use — so renaming a group changes its glyph. See icons and imagery.
Reading it
import {
appDefaults,
featureColumns,
featureFilterDefaults,
featurePageSize,
} from "statshub-config";
featurePageSize("screener", isMobile); // board override, else the global pair
featureColumns("screener", true); // only the columns on by default
featureFilterDefaults("screener").oddsRange; // { from: 1.3, to: 10 }
appDefaults().hitRate.windows; // ["L5", "L10", "L15"]featurePageSize takes the breakpoint rather than reading it, so the caller's
own useIsMobile stays the single source of that answer.
What is not in here, and why
- Copy. Labels are translated; they belong in
public/locales. Alabelin this package is an id a developer reads, not a string a user sees. - Secrets and endpoints. Environment, not configuration — this file is readable by the client.
- Anything derived. If it can be computed from the data, compute it. A config value that duplicates a query result is a value that goes stale.
Still hardcoded
The schema covers the whole shape, but only Prop Screener's page size reads
from it today. Migrating a board is: move its DEFAULT_FILTERS into
filters.defaults, its column list into table.columns, and its mobile card
fields into card.rows, then delete the constants. Do one board at a time and
check the board still starts where it did.