Workout harder, live longer. Challenges made for Families.

Workout harder, live longer. Challenges made for Families.
Tailwind CSS and a polished UI

Last spring my Dad I were trading screenshots of Strava workouts in the family group chat. Someone would post a long run, someone else would reply with "nice," and that was the entire "competition." If we wanted to know who had the most miles for the week, we had to scroll up and add it in our heads. The motivation was real — we wanted to push each other — but the format was terrible.

I tried open source solutions, and even contributed to one... but they all fell short of my requirements. I am a strong believer in no right/wrong answer and advocate for clear mission requirements.

I knew what we needed, so I built Workout Harder, a self-hosted family fitness app that turns the group chat into something my whole family actually uses. This is a writeup of what it does, why I made some of the design choices I did, and what I learned along the way.


What it actually does

The core loop is simple: someone makes a challenge, family members get auto-added, everyone logs activities (mostly through Strava sync), and a live leaderboard updates as the data comes in. Pick a metric — distance, time, elevation, points, calories — pick an activity type, pick dates, hit go.

A challenge could be "Most miles run in May." It could also be "Total ski days at Telluride in March." Or "Anyone who climbs more than 10,000m this year wins." The thing I cared most about: the challenge creator shouldn't have to do any math. Once the challenge exists, the leaderboard takes care of itself.

There are four things that took the app from "nice toy" to "thing my family actually opens":

  1. Family groups. A persistent space where anyone in the group is automatically added to any challenge a member creates. You don't have to re-invite everyone every month. You just exist in the group, and when someone fires off a "May Distance" thing, you're in.
  2. Real activity sync. Strava OAuth + webhook handles the heavy lifting. I also built a small REST API so my brother (who uses Apple Health rather than Strava) can push workouts via an iOS Shortcut. The same endpoint takes Health Connect data from Android. Everyone in the family lives on a different platform; the data still ends up in the same place.
  3. A fair scoring system. This is the one I spent the most time on. More on that below.
  4. Direct messaging and challenge chat. Trash talk is a feature. There's an inbox-style DM system and a per-challenge discussion thread, both with HTMX polling so messages appear without a refresh. The little red notification badge in the nav was non-negotiable.

There's also a trophy/achievement system, weekly digest emails on Sunday at noon (because Sunday is the natural reset day), a unit-preference toggle (I'm imperial, my brother is metric, the app converts at render time so we each see our preferred units regardless of what anyone else picked), public profile pages, and family-wide charts that show how everyone's progressing relative to each other. None of those are headline features but each of them removes a small friction.


The point system

When you're comparing a 5k run to a 60-minute lifting session to a day of skiing, there isn't a single obvious metric. Distance favors running and cycling. Duration over-rewards lazy hikes. Calories are heart-rate-dependent and inconsistent across devices. I wanted something defensible — both fair across activities, and fair to people who don't all do the same sports.

The answer was already published: METs, or Metabolic Equivalents of Task, from the Compendium of Physical Activities. It's the standard reference public health researchers use to compare exercise intensity. A MET is the ratio of energy expenditure during an activity to resting metabolic rate. Running at a 10-minute pace is 9.8 METs. Walking briskly is 3.8. Alpine skiing is 5.3. Squash is 12.

The formula:

points = (MET × duration_minutes × heart_rate_multiplier) + elevation_bonus

The heart-rate multiplier slides from 0.85 at resting (~60 bpm) to 1.30 at all-out (~180 bpm), so if you have a watch that measures HR, your hard sessions count for more. The elevation bonus (0.1 points per meter of climb) only applies to activities where climbing is real work — running, hiking, cycling, skiing, climbing. Lifting weights at altitude doesn't get an elevation bonus, which is correct.

The result feels right. A 30-minute easy run is ~290 points. A 45-minute hard run with hills is ~600. A full ski day is ~1800. A 60-minute yoga session is ~150. The yoga person isn't being punished — they earned 150 points doing the thing they like. The runner earned more, fairly, because running at intensity costs more energy. The system doesn't decide which sport is "better." It just measures effort.

Building it as a stored points field on every activity rather than computing on the fly made aggregation queries trivial. Every time the formula changes, a recalculate_points management command sweeps the whole DB and updates everything. The Strava sync, the iOS Shortcut ingest, and the manual entry all flow through the same Activity.save() which auto-computes points. Decoupled from where the data came from, consistent regardless.


The trophies

Once you have a fair score, gamification writes itself. Trophies are hardcoded in a Python file — 36 of them across 10 categories: points milestones, distance milestones, activity counts, time, elevation, streaks, variety, social, single-activity feats, and firsts. Each one is tiered bronze / silver / gold / platinum / diamond, with matching colors.

The rules are simple lookups against profile stats or activity queries. A "Marathon Finisher" trophy fires the first time you log a single run >= 42.2 km. "Renaissance Athlete" requires activities in 5 different sports. "Centurion" needs a 100-day streak. The award table is unique on (user, trophy_key) so you can only earn each one once.

What I like most about this design: when I want a new trophy, I edit definitions.py and ship. No migration, no admin UI to fill out. The next time any user's stats refresh, they get any newly-applicable badges automatically — even retroactively. That's how I backfilled 48 trophies across 4 family members in one command.

The "you just earned a trophy" banner on the dashboard uses any award from the last 24 hours, so it gives credit even if you crossed the threshold between sessions. Small thing, big dopamine hit.


The technical choices

The whole thing is a Django monolith, deliberately. I built earlier projects on React + Node + Postgres + Redis + serverless functions and spent more time on the infrastructure than the features. This time:

  • Django 5 for everything, server-rendered templates
  • SQLite in a bind-mounted file. ~600 activities and a handful of users barely makes the DB break 250KB. When that becomes a problem I'll switch, but it isn't a problem.
  • Tailwind via CDN for styling. No build step. No PostCSS. No tailwind.config.js. Inline color tokens in the <head>. I have one HTML file and one CSS classname namespace to think about.
  • HTMX for interactivity. The challenge chat, the DM panel, the leaderboard refresh, the trophy banner — all hx-post and hx-get with outerHTML swaps. No SPA. No state management library. Form submissions return HTML partials. The polling cadence is 10-15 seconds, which is enough for a chat between four people.
  • Chart.js via CDN for the data viz. Aggregation happens server-side, gets serialized through {% json_script %}, Chart renders it client-side. The family-wide line chart, donut, and stacked-bar took maybe 200 lines including the data pipeline.
  • A small Python scheduler container instead of cron, Celery, or APScheduler. Pure stdlib. It calculates the next event time from a list of (name, next_run_fn, args) tuples, sleeps until then, runs the subprocess, repeats. Currently handles hourly Strava sync, daily backups at 3am, and weekly digests Sunday at noon.

Two containers in docker-compose: web (gunicorn) and scheduler. That's the whole deployment. SQLite, media, and backups are all bind-mounted to the host so rebuilding the image never touches data.

Automatic gzipped backups run before every container start, daily at 3am, and on-demand. There's a restore_db management command that takes a backup filename and rolls the DB back, saving a db_pre_restore_*.sqlite3 snapshot first as a safety net for the safety net. I have not yet needed to use it, but I sleep better knowing it's there.


What I learned by self-hosting

There's a particular kind of freedom that comes with running your own thing. I added an iOS Shortcut endpoint at 11pm on a Tuesday because I felt like it. The Strava sport-type mapping had a bug where alpine ski activities were classified as "Other" — I fixed it, deployed, and ran a one-command resync of 593 activities. There's no product manager to convince, no analytics dashboard to justify the work against, no "let's see if this lifts engagement" A/B test.

The trade-off is that nobody else uses it. My family does. That's the entire user base. When my sister-in-life Caroline signs up, she'll be the fifth user. The marginal cost of a new feature is the time I spend building it; the marginal benefit is one notification she'll see on a Sunday morning. That ratio still pencils out for me, because I'm building it for people I love, and they're using it, and that's the only metric I actually care about.

If you want to do the same thing — build a small, self-hosted thing for your specific people — I'd encourage you to skip the part where you try to make it scalable or marketable. Pick a stack you can hold entirely in your head. Use a database that fits in a single file. Don't add a queue until you need a queue. Bind-mount your data. Make the deploy docker compose up -d --build. Optimize for shipping, then for shipping the next thing.


Latest features:

Stuff I wanted to build next, and just did:

Real-time challenge end notifications. When a challenge ends and a winner is crowned, the family should get pinged.

Custom challenge templates. Right now every challenge is built from scratch. I want "Run 5km a day for a month," "Bike commute streak," "100k climbing month" as one-click setups.

A weekly photo prompt — everyone uploads one photo of their workout each week. Closer to the group-chat aesthetic that started this whole thing.

Push notifications via a PWA wrapper. The DM badge is nice but a real notification is better.

The repo is at https://github.com/workhardbekind/workoutharder. MIT licensed, fully self-hostable, instructions in the README. If you stand it up for your own family, let me know — that would make my week.

Workout Harder. Be Kinder.