rallyready/
Self-directed badminton training for a player with no coach and no partner. The app is the random caller. Every corner is spoken out loud, so a whole session can go by without you looking at the screen once.
Training alone means nobody is shouting a corner
Badminton footwork gets trained by someone shouting a corner at you. Alone, there is nobody to shout, no partner to feed, and often no court. What is left is a dozen YouTube tabs and no record of whether any of it worked.
Audio first, and load instead of minutes
- Audio first. Every call is spoken, carries its own tone and buzzes the phone. You cannot watch a screen and move to a corner at the same time, so the screen is optional.
- Split-step training. An optional metronome tick fires a configurable 0.2 to 0.7 seconds before each call, so you learn to land the split-step as the call arrives. That timing is the most common club-player fault I know of.
- Deception that actually deceives. A fake call, then the real one, forcing a second split-step. The fake sounds identical to the real thing, because a fake you can hear coming trains nothing.
- Load instead of minutes. One tap rates effort from 1 to 10. Effort multiplied by minutes is the number that compares a brutal ten minutes against an easy half hour, and the app watches the ramp. Training alone means nobody else is watching for the overuse injury.
- It bends to the day. Three taps for sleep, legs and energy, and the session gets lighter or heavier. A written plan cannot tell that you slept four hours.
- Periodised programs. Four to sixteen week plans built from four choices, moving through Base, Build and Sharpen, with a deload every fourth week and a taper at the end. Today's session is the first card on the home screen.
A coach in Manila does not shout in English
Most of the players this is for are Filipino, and it called every corner in English. On a court in Manila a coach shouts likod kaliwa, and then, in the same breath, smash. Taglish is not a translation of the call. It is the call.
So the vocabulary is split where players split it. Corners, zone numbers and phase words are Filipino. Shot names and exercises stay English, because nobody has ever shouted a Tagalog word for a smash, and push up is what is said in a Filipino gym. The interface stays English too: it is read sitting down, with time to think, which is the opposite of a call.
Every phrase is written twice. Android usually ships a fil-PH voice
and can say the real word; where there is none, the call is respelled so an
English voice still produces something a player can move to. Which voice reads
kah-lee-wah is the difference between an instruction and a noise, so the
voice is now a per-language setting. It had been stored since launch and never
read: the function that would have applied it had no callers, so every session
the app ever spoke used whatever the browser picked.
648 tests, and the parts that decide how hard you train
There are 648 tests under Vitest, and npm run verify
(typecheck, lint, tests, build) is what CI runs on every push. The drill engine,
the load model, the program generator and the benchmark scoring are all pure
functions with tests behind them, because those are the parts deciding how hard
somebody trains.
It installs as a PWA. The timer, the drills and the coaching notes work with no connection at all, and sessions still log. The accounts layer behind that is built and tested rather than live: Supabase auth, a full repository implementation and local-to-account migration all exist, and the app ships local-only until the owner adds two keys. The README says so plainly, because a tester deserves to know that before investing a month in it.
Green tests, and an app still wrong in the hand
The tests were green and the app was still wrong in ways only using it on a phone would show. Six of them, found by treating it like a real session rather than a test run.
-
A drill interrupted by a phone call logged itself as complete.
requestAnimationFramestops in a hidden tab whileperformance.now()keeps counting, so the first frame back delivered every event from the whole absence at once. Every skipped corner counted as answered, and if the session's completion event landed in that batch, a drill abandoned at round two wrote itself to the log as finished. The clock now stops when the page goes away, which is also the truthful reading: you were not training. Reflex Rush had the same shape and now abandons a backgrounded game rather than scoring whatever had been tapped. -
The session summary's three buttons were 24px tall.
flex-1inside aflex-colsetsflex-basis: 0on the height, so three buttons shared 75px on every phone. Four other button groups had it too. -
Export could silently do nothing. Both download paths
revoked the object URL on the line after
click(), which can pull the blob out from under a download that has only been queued. Chrome tolerates it, WebKit often does not, and neither anchor was in the document, which Firefox requires. This one mattered because local storage is the only place training data lives. - Two fields made iOS zoom the page in and never zoom back out, because Safari does that to anything under 16px on focus.
- Filter chips at 30px and segmented controls at 36px were both under the 44px touch minimum the rest of the app already kept.
Two findings, and the first was worse than he could see
The app went out to club players, one of whom works in QA. His first point was that it only saves in the browser: clear your cache or change phone and a month of training is gone. The accounts layer was already written and merely switched off, so the answer looked like pasting in two keys.
It was not. supabase/schema.sql did not run at all. Two
alter type ... add value statements had been pasted into the middle
of its enum block, splitting the block and orphaning an end if;, so
Postgres rejected the file on its first statement. Anybody following the file's
own instructions would have got an error and no tables. The feature he asked for
could not have been switched on even with the keys in place.
A broken schema also hides its own drift, and there was some: one enum had lost track of a value added two phases earlier, and another never gained two more. Applied to a real PostgreSQL 16 after the repair, twice from empty: 12 tables, every one with row-level security, 41 policies, 11 enum types, 30 seeded drills, no errors on either run. A test now checks the structure and that every enum value the app uses is declared, and it was verified by putting the original defect back and watching it fail.
His second point was that the app read as too technical for players who are not developers. That is an opinion until it is measured, so it was measured, on the text the browser actually rendered, across fifteen screens.
Sentences longer than twenty words
25 to 0Longest sentence in the app
38 to 19 wordsTotal words a player has to read
4,501 to 3,867Reading grade, same information
4.2 to 3.6Fourteen per cent fewer words for the same content. Deload is now Easy week; Effort × minutes is how hard you trained, not just how long. What did not change is the specificity: no payments are connected yet is still there and still blunt, only shorter. Simpler was not allowed to mean vaguer, and a test holds that line, capping every drill and program string at twenty words a sentence.
The most complete thing I have built
It is the most complete thing I have built. Five phases, a real domain model, an offline story, an audio pipeline, and enough tests that I can change the program generator on a Tuesday without being afraid of it.