Berkan Türkoğlu
Projects
Django · FastAPI · LangGraph

PaceUp

AI-powered running coach (iOS) · personal project

Repositorystatus: live

The problem

I used to ask AI for running plans myself. Every time I had to explain my age, my weight, my current pace, how many days a week I was free — and what came back was a list. That was the problem: the plan stays inside the chat as text, and after a while you stop going back to it and drift off. In PaceUp you fill in your profile once. The chatbot already knows you, so from then on you just state the goal — "I want to run a half marathon in three months". The moment you confirm it, the programme lands in the app's calendar day by day. You tick the workouts off there, and the app reminds you with a notification the day before.

Technical details

One save, four independent effects

Finishing a workout creates one WorkoutResult, and Django signals handle the rest: the workout is marked complete, the programme counter increments, user stats and streaks recalculate, and an achievement check fires — sending a notification if one is earned. Each effect is independent and lives in its own Django app. Deleting the result runs the same chain in reverse, so counters and streaks stay consistent.

Counters that survive a race

I increment programme and user counters with F() expressions. The arithmetic happens inside the database rather than as a read-modify-write in Python. Two workouts finishing in the same second can't overwrite each other's count.

Subscriptions that survive a race too

RevenueCat verification has to be idempotent, because the webhook and the app itself can both try to confirm the same purchase at once. I match on the RevenueCat app user id and the store's immutable original transaction id — the same id used to trace a refund or dispute later. I also keep the timestamp of the last verification, for debugging.

An LLM that can't claim work it didn't do

I split the agent's tools in two. UI tools open a typed form in the app instead of emitting free text. Exactly one backend tool actually writes to the database: create_workout_plan. The model can write "I've prepared your programme" and it means nothing on its own — a plan exists only once that backend tool has really run.

Two services, one auth system

The FastAPI service verifies the JWTs Django issues, using the same signing key over HS256, and reads the same PostgreSQL database. So there is no second login screen, no token exchange, and no second user table to keep in sync. LangGraph's checkpointer picks conversations up where they left off, even across a restart or a redeploy.

Tech stack
Backend
Python 3.12 · Django 6 · DRF · SimpleJWT · django-q2
AI service
FastAPI · LangGraph · LangChain · AWS Bedrock · SSE
Data
Aiven managed PostgreSQL · S3
Infra
Hetzner · Docker Compose · Caddy · GitHub Actions
Mobile
TypeScript · Expo · React Native · RevenueCat
By the numbers
3
services, one monorepo
6
Django apps
18+
REST endpoints
4
signal receivers

Architecture

PaceUp — three services, one monorepo
Django 6 · FastAPI · LangGraph · Hetzner
mobile — Expo / React NativeTypeScript · Expo Router · RevenueCatcalendar plans · AI coach chat · remindersREST · api.paceupSSE stream · chatbot.HETZNER · paceup-prod-1 — docker compose -p paceup (moved off AWS ECS Fargate)Caddy — automatic HTTPScerts issued + renewed automaticallyTLS volume survives deploys via -p paceupdjango:8000graph-api:8001BACKEND/ — DJANGO 6 + DRF6 appsusers · programs · activitygamification · notifications · analyticsSimpleJWT · Google / Apple sign-indjango-q2 async tasks · S3 media4 signal receivers — the event-driven coreCHATAPI/ — FASTAPI + LANGGRAPHAI running coachPOST /chat-stream (SSE) · GET /healthcheckpointer — survives redeployUI TOOLSopens a typed formin the app, not textBACKEND TOOLScreate_workout_planthe only side effectsame DJANGO_SECRET_KEYHS256 — one auth systemAWS BedrockLLM inferencevia LangChainAiven managed PostgreSQLexternal — no database container on the boxshared by django and graph-apiCI/CDGitHub Actionspush to main→ paths-filter→ ssh to host→ rebuild only the changed service
One save, four independent effects
Django signal chain
POST_SAVEWorkoutResult savedthe user finishes one runWorkout marked completedis_completed = True · status = completedProgram counter incrementedF() expression — atomic, race-proofUser stats recalculateddistance · duration · current + longest streakAchievement checkpost_save on User · gamification appNotificationpost_save on AchievementPushto devicePOST_DELETEDeleting the result unwinds the same chain in reverse — counters, stats and streaks stay honest.

The app

Today's session — distance and streak both arrive via signals
The generated programme, spread across the calendar day by day
The active programme — 48 workouts over 12 weeks
Profile and premium membership state, verified through RevenueCat