Case study
Dryad — Focus Session Tracker
[01] batu@batu0:~/case-studies/dryad
A personal focus-session tracker built entirely solo — product thinking, backend, mobile, animation, DevOps. You plant a virtual tree at the start of a work session; it grows as time passes. Abandon the session and it dies. The visual centrepiece is a De Morgan-aesthetic nymph (a Greek hamadryad — a wood nymph whose lifespan is bound to a single tree) from whom the tree grows. Named accordingly.
No AI, no monetisation, no tracking.
The idea
Forest is a well-known productivity app on this pattern. I had years of personal session history in it and wanted to understand the mechanics from the inside — and own the data. The engineering brief wrote itself: build the same core loop, add forensic logging the original doesn’t have, and do it in a stack I’d pitch to a client.
What I built
Offline-first sync
Sessions are created in local SQLite immediately so the timer starts without a network round-trip. A background sync service finds unsynced rows and pushes them to PostgreSQL on reconnect. Forensic log events queue locally and replay in order — so the server history is always reconstructable even after extended offline use.
Timer state machine
Deterministic FSM: IDLE → RUNNING → PAUSED → FINISHED (with ABANDONED exits). When the app is backgrounded, a timestamp is recorded; on foreground, elapsed time is corrected (elapsed += floor((now − backgroundedAt) / 1000)) so the timer stays honest across lock-screen and multitasking. The FSM was the first thing designed and tested — nothing else touches time state directly.
Forensic session logging
Every timer event (START, TICK, PAUSE, RESUME, ABANDON, FINISHED, APP_BACKGROUND, APP_FOREGROUND) is written to session_log_events with millisecond-precision timestamps. This gives you the exact interruption points, device-suspension detection, and a fully re-constructable session history — something Forest’s export doesn’t expose at all.
SVG tree animation
The nymph figure and tree are drawn with react-native-svg. Branch growth uses strokeDashoffset cubic-bezier draw animation; leaves use Animated.Value scale/fade. Six visual stages tied to elapsed-time percentage. Tag colour tints branches (full HSL saturation) and leaves (HSL-lightened). At completion the nymph opacity fades while the tree silhouette intensifies — a “merging” effect that plays on the hamadryad mythology.
JWT auto-refresh with request queueing
Axios interceptor detects 401 responses, fires one refresh request, queues all other in-flight requests, and replays them after the new token arrives. Tokens stored in Expo SecureStore (OS-level secure storage). Prevents the double-refresh race that breaks most naive implementations.
Forest data import
A CLI script (scripts/import_forest.py) auto-detects CSV or JSON format, parses Forest’s date format, and bulk-imports 110+ personal tags and historical sessions via the API. Seeded the stats view with years of existing data on day one.
Adaptive push notifications
21 local notifications (3 daily × 7 days) rescheduled on every app foreground. Copy shifts tone by streak count, days since last session, last tag used, and tree stage reached. No server required — Expo handles scheduling. Sardonic by design.
Microservice discipline
Each service owns its tables; stats_service uses reflected ORM models (read-only). Shared db.py lazy-initialises the async SQLAlchemy engine so services don’t race on startup. Routes are thin; business logic lives in service classes. Every route has happy path, 401, 422, and 404 test coverage (TDD throughout).
Stack
| Layer | Technology |
|---|---|
| Mobile | React Native, Expo Managed, Expo Router (file-based) |
| Mobile state | Context API + useReducer (5 contexts) |
| Mobile animation | react-native-svg, react-native-reanimated |
| Local storage | Expo SQLite (sessions), AsyncStorage (tags/auth cache), SecureStore (tokens) |
| Backend | FastAPI + Uvicorn, Python 3.10+ |
| ORM / migrations | SQLAlchemy (async + asyncpg), Alembic |
| Database | PostgreSQL 16 |
| Auth | JWT (python-jose + passlib/bcrypt) |
| Gateway | Nginx |
| Infra | Docker Compose (6 containers) |
| Testing | pytest + pytest-asyncio (backend), jest-expo + Testing Library (mobile) |
Scale & outcomes
- Personal project — the only client is me and my session history.
- ~87 commits, 13 implementation plans (all FINISHED), Apr 2026.
- MVP shipped with full stats view: 52-week heatmap, bar charts, streak counter, forest gallery.
- Seeded with 110+ Forest tags and years of historical session data via the import script.
- Post-MVP deferred: Firebase multi-device sync, BREAK/LONG_BREAK phases (FSM ready, UI deferred), tag merge tooling, CSV export.