BikeControl (SwiftControl)
Use Zwift Click, Zwift Play, Shimano Di2, or any Bluetooth controller with MyWhoosh, Rouvy, Zwift, and more. Free to try on iOS, Android, Windows, and macOS.
Daily one-page staff report from force plate, GPS and wellness data, with the coach's review before anything is shared. Fictional roster.
Sports & fitnessPython · JavaScriptGitHub @maurocastillomdOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗Every morning, one page that tells the staff which players to talk to before they train, and why.
See the live sample card · How to use (1 page) · IT fact sheet (1 page)
The sample card for a fictional Sep 20. Fictional club, players and data.
Each player is compared with his own last 4 weeks, not with the team. A flag means his drop is bigger than his normal day-to-day ups and downs and bigger than the test's own noise. A coach reviews every flag before the card is made. It is a reason to talk, not a decision to sit him.
Built by Mauro Castillo. The rest of this page is the full detail: the data, the rules, the math and how to run it.
A daily one-page staff report from force plate, GPS and wellness data, with the coach's review before anything is shared.
Fictional roster and fictional data. The club, the 28 players, the opponents and every number are invented. No real athlete data is in this repository.
Not a fitness-to-play decision. The card does not predict injury. It shows where a player has changed from his own normal, so the staff knows who to talk to first.
His own normal. Each player is compared with his own last 28 days. He needs at least 10 readings in that window. With fewer, his status is "Not enough data".
The jump. The flag metric from the force plate is RSI-mod (jump height divided by time to take off). Each test day, a jump trial more than 10% away from the median of that day's trials is dropped. This 10% rule is a practitioner convention, not a published cutoff. The remaining trials are averaged. Fewer than 2 good trials means no jump value that day. Jump height is shown but never flagged, so the same signal is not counted twice.
Wellness. The morning wellness score is the second thing that can flag, also against his own 28 days.
Gate 1, the z-score. A z-score says how far today sits from his normal, in units of his usual spread (standard deviations, SD).
Gate 2, test noise. The drop must also be bigger than normal test-to-test noise. Noise is the typical error (TE), estimated from the squad's own back-to-back readings:
TE = SD of day-to-day differences / sqrt(2)
The drop must be larger than 1.5 x TE. Mauro Castillo chose 1.5 x TE on 2026-10-21 as a middle ground for a daily report. For reference, 1 x TE is the loosest common rule and the 95% minimal detectable change (about 2.77 x TE) is the strictest. No validated cutoff exists for countermovement jumps in pro baseball. A big z-score that stays inside 1.5 x TE becomes "No change", and the review screen says so. For the two-measure Check in rule, each measure must pass its own noise gate.
The four status words.
Signal and noise (review screen). The review screen shows how sensitive each measure is for this squad, and how big each player's change is in every unit that matters. Worked numbers are RSI-mod on the fictional 2026-09-20.
| Metric | Formula | 2026-09-20 RSI-mod |
|---|---|---|
| TE, typical error | SD of day-to-day differences / sqrt(2) | 0.032 m/s |
| CV | TE / squad mean x 100 | 6.1% |
| SWC, smallest worthwhile change | 0.2 x between-player SD of each player's normal | 0.008 m/s |
| SWC / TE | SWC / TE | 0.26 (marginal) |
| MDC95 | 1.96 x sqrt(2) x TE | 0.087 m/s |
| Your gate | 1.5 x TE | 0.047 m/s |
| z | (today - his mean) / his SD | J. Ortega: -2.18 |
| Change in TE units | (today - his mean) / TE | J. Ortega: -2.9 |
SWC / TE below 1 means one reading cannot settle a change as small as the SWC (Hopkins 2000). That is why a flag also needs the noise gate. The rating bands (1 or more good, 0.5 or more OK, below 0.5 marginal) are a convention.
Two different "SWC" numbers appear in this project. The table's SWC is Hopkins' version: 0.2 x the spread between players. The "three straight declines" Watch rule uses 0.2 x his own SD, which the review data calls swc_personal.
Context never sets a status. These are shown next to the status, never used to make it:
Team-shift days. Sometimes the whole roster jumps lower, for example after an early-morning travel arrival. When the team's median jump change is beyond the same 1.5 x TE gate, the card's headline says so, such as "The whole team is jumping 10% below its usual today" (the fictional 2026-09-15 travel day), and the line under it counts the players whose jump fell more than the team's by more than the noise gate. Boxes go only to the flagged players "Not explained by the team drop": those players, plus anyone whose flag did not come from the jump. Last comes a names-only "Down with the team" line. This is grouping only. No status changes on a team-shift day. The team line uses the jump (RSI-mod) only.
Two kinds of wording. The review screen is for the analyst and shows z-scores and both gate results. The card is for the coaching staff and uses plain words: today's jump as a percent of his own usual. It is a morning brief: one headline ("2 players to talk to before they train"), one box per flagged player with short reasons, an action line and a two-week jump picture, then everyone else behind one "Show all 28 players" button, then two sentences on how to read it. In the jump picture the shaded band is z from -1 to +1, labelled "his usual range"; today's dot is hollow when the reading is not from today. Analyst words (z, TE, SWC, MDC95) stay on the review screen, inside "For analysts: how noisy each test is".
Print and PDF. The PDF is how the card travels (Slack, Teams, email), so the printed page is designed first. It is always one full US Letter page: the top line, the headline and sub line, the flagged players, then the whole roster as two tables grouped by position (player, status, jump vs his usual, morning form, lineup) at 9 to 10pt, and "How to read this" at the bottom. Up to 4 flagged players print as boxes with the two-week picture; 5 or 6 print three across without pictures and with their first two reasons; 7 or more print as a one-line list with the reason that drove the flag. 12 or more drop the roster to its 9pt floor (printLayout in web/js/logic.js). On a team-drop day, a flagged player the drop explains shows "Down with team" with a grey mark in the roster, on paper and on screen; his status, export and review stay Check in or Watch. "Save PDF" names the file "Lineup Card ".
Example review. web/examples/review-2026-09-20.json is a sample review for 2026-09-20. When no saved review exists in the browser, the card uses it and says "example review".
Availability board. An isometric board with one peg per player, drawn with the anatomy kit in web/figures/board/, is kept in the repo as a figure. Since the 2026-10-28 redesign the card no longer shows it and the offline file no longer carries it. The kit is vendored under its MIT license, kept as is.
You need Python 3.11 or newer (built on 3.13). Node is only needed for the web tests. Run these from the lineup-card folder:
python3 -m venv .venv
.venv/bin/python -m pip install -e '.[dev]'
.venv/bin/python scripts/generate_fictional.py
.venv/bin/python scripts/build_site.py
python3 -m http.server 8765 --directory web
Open http://localhost:8765, mark today's players, decide on each flag, approve. Approving opens the card. You can also go straight to http://localhost:8765/card.html.
The generator is seeded, so every run makes the same files.
The morning job pulls yesterday and today from VALD and Catapult, then builds the live site. Yesterday is pulled again because last night's game happened after the morning pull.
.venv/bin/python scripts/pull.py --date yesterday --to today --data data/live
.venv/bin/python scripts/build_site.py --data data/live
python3 -m http.server 8765 --directory data/live/site
scripts/morning.sh runs the first two lines. If one source fails (pull exit code 3), it still builds the site from the source that worked, and the card says which data is not from this morning. It then ends with code 3 so the log shows the problem. A missing setting (code 2) stops it before the build. It reads the settings from the macOS Keychain, so no key sits in a file. Store each setting once (macOS asks for the value):
security add-generic-password -a "$USER" -s lineup-card.VALD_CLIENT_SECRET -w
Repeat for each name in .env.example that you use. Both schedules below are documented here and are not installed.
Both schedules write the log to ~/Library/Logs/lineup-card-morning.log, inside your own user folder (only your account can read it). The log holds call names and HTTP status codes, never keys or tokens.
launchd (macOS). Save as ~/Library/LaunchAgents/com.lineupcard.morning.plist, then run launchctl load ~/Library/LaunchAgents/com.lineupcard.morning.plist. It runs at 7:00 a.m. launchd does not expand ~, so replace YOUR_USER with your macOS user name.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.lineupcard.morning</string>
<key>ProgramArguments</key>
<array><string>/bin/zsh</string><string>-c</string><string>~/lineup-card/scripts/morning.sh</string></array>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
<key>StandardOutPath</key><string>/Users/YOUR_USER/Library/Logs/lineup-card-morning.log</string>
<key>StandardErrorPath</key><string>/Users/YOUR_USER/Library/Logs/lineup-card-morning.log</string>
</dict>
</plist>
cron. The same job as a crontab line:
0 7 * * * ~/lineup-card/scripts/morning.sh >> ~/Library/Logs/lineup-card-morning.log 2>&1
The Keychain must be unlocked (you are logged in) for either schedule to read the settings. The fictional demo is rebuilt with plain scripts/build_site.py and never touches data/live.
Status: built to the vendors' published documents, tested against mocked responses, not yet tested against a live account. Every fact and its source is in docs/api-sources.md, with a list of what is still unverified.
| Part | Built | Tested with mocks | Tested live |
|---|---|---|---|
| VALD token, cached until it expires | Yes | Yes | Not yet |
| VALD tests, paged until the end, repeats removed | Yes | Yes | Not yet |
| VALD trials: jump height and RSI-modified found by name, scale factor and units checked | Yes | Yes | Not yet |
| Catapult activities for the club day | Yes | Yes | Not yet |
| Catapult stats by player and period, session totals skipped | Yes | Yes | Not yet |
| Catapult throw and pitch counts | Yes, once the club sets the slugs | Yes | Not yet |
| Inbox CSVs in the same format as a manual export | Yes | Yes (read back by the CSV readers) | Not yet |
Pull, then build the card from data/live | Yes | Yes | Not yet |
| Wellness form | Not from an API. Drop wellness.csv into data/live/inbox/<date>/ | Yes (CSV) | Not yet |
Getting VALD keys. Email [email protected] and ask for External API access. Include your VALD Hub organization ID. VALD sends an API License Agreement to sign. After that they send a client ID and client secret through a link that expires in 7 days. You also need your tenant ID (from the Tenants API or VALD support) and your region: use (United States East), aue (Australia East) or euw (Europe West).
Getting a Catapult token. Catapult has to enable API access on the club's OpenField account. Once it is on, an API token can be made in OpenField Cloud. Ask your Catapult contact if you do not see the option. Your host is us, eu or apac. Throw and pitch counts come from Catapult's baseball module, and their parameter names differ by account. Ask Catapult for the two slugs, or list them with the API's /parameters call, and set CATAPULT_THROW_SLUG and CATAPULT_PITCH_SLUG. Until then those columns stay blank.
Settings. All settings are environment variables. .env.example lists every name with an empty value.
| Variable | Needed | What it is |
|---|---|---|
VALD_CLIENT_ID, VALD_CLIENT_SECRET | Yes, for VALD | Keys from VALD support |
VALD_TENANT_ID | Yes, for VALD | Your team's tenant ID |
VALD_REGION | Yes, for VALD | use, aue or euw |
VALD_CMJ_TEST_TYPE | No | Test type code for the jump. Default CMJ |
VALD_JUMP_RESULT, VALD_RSI_RESULT | No | Exact result name, idString or id, if the defaults are not found |
CATAPULT_TOKEN | Yes, for Catapult | API token from OpenField Cloud |
CATAPULT_HOST | Yes, for Catapult | us, eu or apac |
CATAPULT_LOAD_SLUG | No | Default total_player_load |
CATAPULT_THROW_SLUG, CATAPULT_PITCH_SLUG | No | Baseball module slugs. Blank columns until set |
Matching players. data/live/crosswalk.csv has the same columns as the fictional one: lc_id,name,pos,vald_external_id,catapult_name. Set each player's External ID in his VALD profile to the vald_external_id value, or put his VALD profile ID in that column. catapult_name must match the athlete name in OpenField exactly. Anyone not in the crosswalk is held out and listed in the review, never guessed. schedule.csv is optional for live data.
Club name and labels. Make data/live/club.json:
{"name": "Your Club Name", "synthetic": false}
With "synthetic": false the card and review page drop the "Fictional roster" label (they always say "Not a fitness-to-play decision."), and leave out the availability board figure, which is drawn for the fictional roster. Without this file the card keeps the fictional demo labels.
Pull one day by hand.
export VALD_CLIENT_ID=... VALD_CLIENT_SECRET=... VALD_TENANT_ID=... VALD_REGION=use
export CATAPULT_TOKEN=... CATAPULT_HOST=us
.venv/bin/python scripts/pull.py --date 2026-09-20
.venv/bin/python scripts/pull.py --date 2026-09-20 --source vald
It prints one line per source: rows written, players, and which dates got files. Every day in the range gets a file for each source that worked, with only the header row when there was nothing that day, so pulling a day again clears old rows.
Each day folder also gets _status.json, for example {"vald": "ok", "catapult": "failed", "pulled_at": "..."}. The card reads it and shows "Catapult data not updated this morning." when a source failed.
The whole pull stops after 15 minutes (set LINEUP_PULL_BUDGET_S in seconds to change it). Real data goes to data/live (git-ignored). Any other folder needs --allow-any-dir, and the tool warns that the folder is not git-ignored. data/fictional is always refused, however the path is spelled. Files are written to a temporary name and then renamed, so a failed run never leaves half a file.
Security.
invalid_client).CATAPULT_HOST must be us, eu, apac or an https Catapult host name with no path or port..env and data/live/ are in .gitignore. Real athlete data must never be committed or put in a public repository.data/live/site/, never to web/data/, which holds the fictional demo.data/live/club.json with "synthetic": false (see "Club name and labels") before showing the live site to staff. Without it the site still says "Fictional roster".Other notes.
The Club Edition is one HTML file, lineup-card.html, that staff open from disk in a web browser. Checked in Google Chrome. Microsoft Edge uses the same engine but has not been checked yet. The start screen shows three steps and a "See a sample card" button, which opens the finished card for the fictional 2026-09-20 with its example review; "See how it was checked" on that card opens the review behind it. For real use, staff drop in VALD, Catapult and wellness CSV exports plus roster.csv (single files, or a whole folder with "Choose a folder"; the last 4 weeks or more of daily exports, since a player needs at least 10 readings in the 28 days before a day to be flagged), review the flags, approve, and save the card as a one-page PDF with the "Save PDF" button (Ctrl+P works too) to send by Slack, Teams or email. No install, no Python, no internet and no AI inside. A Content-Security-Policy in the file blocks the page from loading or sending data over the network. It does not stop a user clicking a link.
The policy, as built: default-src 'none'; script-src followed by the sha256 hashes of its two scripts (the image library and the app); style-src 'unsafe-inline'; font-src data:; img-src data: blob:; connect-src data:; base-uri 'none'; form-action 'none'. No 'unsafe-inline' for scripts: the build hashes each executed script, and the inline license block (type="text/plain") never runs. Styles keep 'unsafe-inline' because the pages set inline style attributes.
Build the share package.
.venv/bin/python scripts/package_club_edition.py
It rebuilds dist/lineup-card.html (with scripts/build_offline.py), then makes dist/club-edition/ fresh. The script stops if any PDF is longer than one page.
What is in the package.
SAMPLE-LINEUP-CARD-2026-09-20.pdf (and a .jpg preview): the finished card for the fictional Sep 20, printed by the script from the app itself. It shows the end result before anyone opens anything.
lineup-card.html: the app, about 750 KB, with the fictional sample inside.
sample-data/: the fictional roster.csv, schedule.csv and one folder per day of VALD, Catapult and wellness exports.
HOW-TO-USE.pdf: one page. It leads with "What you get" (a picture of the sample card and the four status words), then five steps with pictures (source docs/club-edition/how-to-use.html, pictures in docs/club-edition/shots/).
IT-FACT-SHEET.pdf: one page of facts IT can check: the policy, where data lives, how to delete it, third-party licenses (source docs/club-edition/it-fact-sheet.html).
licenses/: a third-party notice and the full license texts (html-to-image MIT, fonts OFL 1.1). The same texts are inlined in lineup-card.html as a <script type="text/plain" id="licenses"> block.
COPILOT-CHATGPT-PROMPTS.md: optional prompts for the club's approved Copilot or ChatGPT, used only on the approved card text. The AI never decides who is flagged.
HOW-TO-USE.jpg, IT-FACT-SHEET.jpg: picture previews of the two PDFs (1313 x 1700). The package script makes them from the new PDFs each run (macOS qlmanage and sips) and also saves them in docs/club-edition/.
Documented differences from the Python edition. None of them is hit by the fictional data, and the parity test checks every fictional day.
start_time with no time zone is an error. Python reads it as the computer's local time.duplicate_row with a count.nan, inf and numbers with underscores are refused.Full wording: docs/superpowers/plans/2026-10-27-club-edition-offline.md.
Not tested inside any club's network or with a club's real exports yet.
The status engine is copied from Readiness commit 09435aa and extended with the noise gate, the context frames and the team-shift grouping.
.venv/bin/python -m pytest -q
node --test tests/web/*.test.mjs
Name the test files in the Node command. A bare directory fails on Node 24.