
Progrss.it is a habit tracker built around a simple observation: I will happily grind a daily quest in a game and then forget to drink water. So the app borrows the machinery that makes games sticky — streaks, XP, levels, gems, unlockable themes, a weekly leaderboard — and points it at washing up and going to the gym.
That framing sounds like a UI problem. Most of the hard parts turned out to be arithmetic and honesty.
The streak that kept lying
The first version stored currentStreak as a number on the user and incremented it whenever a day was completed. It was wrong constantly. Log a habit late, cross a timezone, complete a day out of order, and the counter drifted from what the logs actually said.
The bug was never really in the increment logic. It was that two things independently claimed to know the streak: the counter, and the log history. Whenever they disagreed, the counter won, and the counter was the one that could be wrong.
Now the streak is derived. streak-core.ts walks the logs and works it out; the stored value is only a cache, written by a single syncStreak() function, and the logs are the truth. If the cache is ever wrong, recomputing fixes it — there is no separate state to repair.
That is the change I would keep if I could only keep one. A number the user emotionally depends on should not be something you maintain by hand in five places.
Midnight, without a cron job
Habits reset daily. The obvious implementation is a scheduled job at midnight that clears yesterday.
Whose midnight, though? Users are in different timezones, and a single job fires once. Worse, a scheduled reset is a moving part that can fail, and if it fails everyone's day silently doesn't roll over.
So there is no reset job at all. Every log carries a dateKey, and completion compares it against today's key computed in the user's own timezone. The reset isn't an event — it's a consequence of the date changing. Nothing needs to run, nothing can fail to run, and midnight is correct for everyone simultaneously.
An economy that has to mean something
Gamification is easy to add and easy to make meaningless. Three rules keep the numbers honest.
Gems are earned, never bought. The earned economy is the product. The moment gems are purchasable, a level stops describing your consistency and starts describing your wallet. Inventory items record their source specifically so "they earned it" stays checkable.
Tasks and journal entries sit outside the game. They earn no XP, no gems, and don't touch a streak. This one is counterintuitive — more features paying out feels more rewarding. But if tasks paid XP, the fastest route to a high level would be inventing chores, and the leaderboard would rank imagination rather than consistency.
Streak freezes are capped, and the cap is enforced when you buy one. A freeze covers a missed day so a single bad Tuesday doesn't erase two months. But with an uncapped stockpile a streak becomes unloseable, and an unloseable streak is just a join date. Capping at purchase rather than at spend means you never lose a freeze you already own.
The pattern across all three: decide what the number is supposed to mean, then refuse to let anything cheapen it.
The infrastructure decides some of the product
A few constraints shaped features more than any design decision did.
Reminders are one daily cron, not per-user times. Vercel's Hobby plan rejects sub-daily schedules, so there is a single firing at roughly 18:00 UTC for everyone. A per-user reminder hour is impossible on that plan, and pretending otherwise would mean a setting that silently doesn't work. Instead there's a quiet-hours guard that skips anyone for whom 18:00 UTC lands in the middle of the night, and the reason lives in a comment next to the constant so nobody re-litigates it in six months.
An earlier version ran hourly from a GitHub Actions workflow. It never fired once. Without the required environment variables on the repo it exited zero, having done nothing — a green tick every hour for a job that wasn't running. Silent success is a worse failure mode than a crash, and it took embarrassingly long to notice.
Rate limiting is Postgres-backed rather than in-memory. The instinct is a Map keyed by IP. On serverless that does nothing: each invocation gets its own memory, so the limit applies per-instance and effectively not at all. A shared counter table is less elegant and actually works.
The database URL has to use the transaction pooler. Supabase's session pooler caps out and exhausts under concurrency; migrations still need the direct connection because they use session features. Two URLs, for two genuinely different jobs.
"It feels AI vibe coded"
The most useful feedback I got was that the app felt AI-generated. It was, in places, and rather than argue I audited the codebase to work out what people were actually reacting to:
- 15 font sizes in use, four of them arbitrary pixel values
- 10 border radii, three arbitrary
- 252 uses of bold or black weight, 42 medium, and zero regular
- 55 uppercase micro-labels
- 46 frosted glass cards — every panel identical
Individually, none of those is a mistake. Together they are the tell. Nothing had a hierarchy because everything was maximally emphasised. A headline stat and a footnote were the same weight, on the same frosted card, under the same shouting all-caps label.
The fix was a deliberately small design system: six type roles instead of fifteen sizes, a fixed set of radii, and a rule that if a value isn't in the system you don't use it. The single biggest improvement was introducing a regular font weight for body text. The app had none — which is precisely why every screen felt like it was shouting at you.
I've come to think this is the characteristic failure of AI-assisted UI work. It doesn't produce ugly screens; it produces screens where every element is individually reasonable and collectively exhausting, because each one was generated without reference to the others. The model optimises locally. Hierarchy is a global property, and you have to impose it yourself.
The stack
Next.js 16 with the App Router, React 19, TypeScript and Tailwind v4. Postgres via Prisma on Supabase. Auth.js for authentication, Zod for validation at the boundaries, Zustand for the small amount of genuinely client-side state, Recharts for the consistency map, Resend for transactional email and web-push for notifications. It installs as a PWA. Deployed on Vercel.
The parts I'd point at aren't in that list, though. They're streak-core.ts, the timezone-aware dateKey, and the rule that gems can't be bought.
What's next
Club wars — a weekly fixture between clubs — are built but stay dormant until enough clubs exist for a draw to be meaningful, then switch themselves on. No feature flag to remember, no launch to coordinate.
Beyond that, mostly restraint. The temptation with a gamified app is to keep adding things that pay out. Every one of those makes the numbers mean slightly less.
You can try it at progrss.it.