Inspiration
This whole thing started with a Reddit post.
A coach (shoutout u/awkpotato) wrote this long, slightly desperate wall of text looking for an app that could do something every scheduling tool on the market apparently can't: assign lesson plans based on how many times a team has visited a station, not what day of the week it is. Their exact pain was something like "every 4th time we go to Area 2 we do this plan, but it's not every Tuesday, it's every 8th week for this part of practice and every 6th week for that part." They were hand writing rotations onto a paper calendar because no software understood the problem.
I read that and thought: that's not a calendar problem, that's a counter problem. And it's completely solvable. So I built Chalk.
What it does
Chalk is a practice scheduling OS for coaches who run multiple teams. The core idea is occurrence-based rotation: every plan rotates by visit count, so a team's warm-ups cycle every 8 sessions, their bar drills every 5, their strength block swaps monthly, and the right plan just shows up automatically when you generate that day's practice.
On top of the engine:
- Dashboard + Calendar for generating and reviewing practices
- Gym Map, a coach × time-slot grid showing who is in which area at any moment
- Lesson Plan library with per-team rotation playlists
- Live share links: share one team's schedule with a public link, and other coaches can import the whole thing (schedule, plans, rotations) into their own account in one click
The rotation engine (the actual idea)
The entire product rests on one formula. For a team $t$ and block type $b$, you keep a playlist of plans
$$P_{t,b} = [p_0, p_1, \dots, p_{n-1}], \qquad n = |P_{t,b}|$$
and a visit counter $v_{t,b}$ that increments only when a practice is generated for a new date. The plan for the next session is:
$$\text{plan} = P_{t,b}\big[\, v_{t,b} \bmod n \,\big]$$
That single modulo is the thing no calendar app does, because calendars index on dates and this indexes on occurrences. It guarantees two nice properties:
- Full coverage before repeat. Over any window of $n$ consecutive visits, every plan appears exactly once.
- Predictable period. A given plan recurs with period $n$, so "every 8th warm-up" falls out of $n = 8$ for free, completely decoupled from the date the practice happens to land on.
Different block types can have different $n$, which is exactly why one part of practice repeats on an 8-session cycle while another repeats on a 5-session cycle. The calendar never enters the math.
How I built it
- Frontend: React 18 + Vite + Tailwind, neobrutalist design, React Context for state with a debounced autosave to the backend.
- Backend: Node (ESM) + Express, MongoDB via Mongoose, Google OAuth through Passport, sessions stored in Mongo so they survive serverless cold starts.
- Deploy: A single Vercel project. The frontend builds to static files and the Express app runs as one serverless function under
/api, with rewrites wiring the SPA and the API together on one origin.
User data lives as one JSON document per account, which made the app fast to build but turned out to be the central design tension later (see below).
Challenges I faced
Deploying to Vercel was the real boss fight. In order, the build failed because of: a leftover service config in vercel.json, then an experimentalServices block that silently set the project framework to "services", then hitting the 12 serverless function limit because every file I had dropped under api/ was being turned into its own function, then a dashboard framework preset I had to override from code. Each fix revealed the next layer. The lesson that actually stuck: when an error names something specific and survives your fix, re-read the config you assume is correct instead of trusting it.
OAuth gave me a great one-character bug. Login kept failing with redirect_uri_mismatch. The culprit was a trailing slash on my APP_URL env var, so the callback came out as ...vercel.app//api/auth/google/callback with a double slash that Google refused to match. I fixed the env var and also hardened the code to strip trailing slashes so it can never happen again.
Sharing broke my data model. Everything for a user lives in a single blob, so "share one team" meant cleanly extracting just that team's slice (its schedule, the plans its rotations reference, its generated practices) out of the blob without leaking the rest. The import side was worse: if I merged into a user's data on the server while their browser still held stale state, the client's autosave would overwrite my merge. I solved it by keeping the public share page outside the app's data provider entirely, so there is no autosave loop running to race against, and the user only loads fresh data after the import completes.
Mobile. The desktop layout looked great and the phone looked broken, because the calendar was a fixed-width sidebar crushing a 7-column grid into nothing. A responsive pass (stack on mobile, scale the display type down so it stops wrapping mid-phrase) fixed it.
What I learned
- Serverless is a different mental model: filesystem conventions, function counts, and cold-start state all bite in ways a normal Express app never does.
- The hardest part of a "simple" feature like sharing is usually the data model you already committed to, not the feature itself.
- The strongest demos come from solving one specific, real, narrow problem completely. A modulo operator that one frustrated coach on Reddit genuinely needed beats ten half-built features.
What's next
Rich link previews (a dynamic Open Graph image rendering the actual calendar), real multi-coach collaboration on a shared team, and an iCal export so practices land straight in a phone calendar.
Log in or sign up for Devpost to join the conversation.