← all projects

sports club platform

a mobile web app for a community sports club where most members are older and sign up with two fields

when
Sept 2026 – now
for
a sports social cooperative
role
solo: design, build, deploy, operate
stack
TypeScript, Express, EJS, Prisma, SQLite, Docker, KakaoTalk messaging API, PWA
size
385 commits, 118k lines, 113 database models
client work, through neoworks

also in 한국어

what it is

a mobile web app for a community sports club run by a social cooperative. members sign up, pick their sports and apply for games: field and screen park golf, baseball, soccer, futsal, badminton, and later youth divisions and tee-ball. the club runs everything from an admin side: games, venues, sponsors, partner shops, notices, member records.

it started as six requirements on september 2. it is now on version 1.95 and has grown a park golf league, a baseball scorebook, a shop and a lunch ordering site. most releases answer a short message from the club president.

the hard part: who is holding the phone

most members are older park golf players. the client asked for that to drive the design, so i treated it as a requirement, not a style.

  • body text is 17px, inputs 18 to 19px, main buttons 60px tall.
  • signup is two fields: name and phone number. no password. the server sets it to the last four digits of the phone and shows it big on the next screen. everything else gets filled in later.
  • one main button per screen. every icon has a korean word next to it. no long press, no hidden menus, five bottom tabs.
  • no alert, confirm or prompt. most people open the link from KakaoTalk, and its in-app browser swallows them. a "copy" button changes its own label instead of popping a dialog.

signup screen with two fields and a big green button signup: name, phone, done. the hint says the phone number is your login and the last four digits are your password.

the less obvious lesson: on a screen like this, "nothing happened" means "press again". one evening the same sponsor got created three times in three seconds, with logo files identical down to the sha256. i fixed it in two layers. a small script locks the submit button and says "saving..." after the first press, and the server accepts each rendered form token only once, so a second save does nothing even when the in-app browser eats the javascript.

the bugs that taught me something

the club lost its only owner. one morning the health check said owners: 0. the audit log showed why. the president had registered his own shop using his own phone number, and the "create shop login" code found an existing member with that number and turned him into a shop account, renamed to the shop. the screen said "saved". i had protected the last owner in the role-change screen, but i'd assumed that was the only place that wrote roles. there was a third one. now a single function writes Member.role, and its UPDATE carries the rule in the WHERE clause: you can't demote someone if they're the last owner. no code path can skip it.

transactions on SQLite. appointing sub-admins inside a Prisma interactive transaction failed outright on a 46-member database: 6463 ms passed, against a fixed 5,000ms budget. SQLite has one write lock for the whole file, so any other writer stalls the transaction until it dies. the fix was to drop the transaction: one conditional UPDATE with the count checks inside it. that runs in 14 to 29ms, and two simultaneous clicks are stopped by the database itself.

counting downloads on finish. coupon stock was decremented on the response finish event. on a real phone the client closes the socket as soon as the last byte arrives, and node often never fires finish. i reproduced it: 30 complete downloads, 11 finish events. so 10 to 60% of coupons went uncounted. the tests had passed because node's fetch reuses keep-alive connections, which phones don't. both the counter and the test client changed.

a test that sent real messages. a staging container inherited the production env and sent real KakaoTalk messages to two fixture numbers that turned out to belong to real people. now every outgoing message passes one guard in the single send function. it blocks test processes and fixture number prefixes, writes GUARD:blocked into the log, and fails closed.

what else is in there

  • a roster engine: queue numbers are never renumbered, and the first waitlisted person moves up automatically when someone cancels.
  • load tested with 5,240 generated members: 24ms worst case, 36ms for the admin member list. member search is one LIKE on a precomputed column, and the member export streams instead of loading every row into memory.
  • a park golf league: round-robin draws, a scheduler that fits matches into tee slots, and an installable scoring app where each phone queues scores offline and resends them with an operation id, so a score is never applied twice. the stage suite runs 104 checks.

where it is now

live, released several times a week, still growing. one container on a shared host, capped at 1GB of memory after a host-wide out-of-memory event in september took it down along with everything else. every release has a rollback image, a database backup taken first, and a read-only check against production that cleans up after itself.