EquiTrail — Architecture & Design Decisions¶
Every significant decision is logged here with rationale so future sessions don't re-debate settled choices.
Infrastructure¶
Cloud Region: europe-west4 only¶
Decision: All cloud infrastructure (Firebase, Cloud Functions, any future services) must be in europe-west4 (Amsterdam) or the closest EU region.
Why: User is in the Netherlands; GDPR compliance; latency; user data stays in the EU.
Applied: Firebase project, GraphHopper server on Oracle Cloud Netherlands.
Routing Server: Oracle Cloud ARM (Netherlands)¶
Decision: Self-hosted GraphHopper on Oracle Cloud free-tier ARM instance.
Why: Free, performant, data stays in NL, horse routing profile customizable.
SSH rule: ONLY via Tailscale IP 100.126.14.49. Public IP 152.70.54.93 is blocked by UFW and must never be used.
GraphHopper Round-Trip Algorithm¶
Decision: Use ?algorithm=round_trip&round_trip.distance=Nm for loop routes, NOT via-point approach.
Why: Via-point creates straight out-and-back; round_trip generates true circular loops.
Android Build¶
Package: com.nossie.equitrail (not com.equitrail.equitrail)¶
Decision: Single package name for phone AND wearable.
Why: Google Play wear:internal track requires watch package = phone package.
History: Previously had com.equitrail.equitrail which caused SHA-1/Google Sign-In errors (DEVELOPER_ERROR 10). Permanently deleted old Firebase apps.
Wearable Distribution: wear:internal track (separate AAB)¶
Decision: Phone AAB → internal track; Wearable AAB → wear:internal track. Both uploaded separately.
Why: AGP 9.x removed wearApp() bundling; Play Console requires separate AAB per form factor.
Note: During internal testing, watch auto-install from phone is disabled — testers must install directly from Play Store on watch.
Version Convention: watch = phone + 1000¶
Decision: phone versionCode N → watch versionCode N+1000. Why: Easily distinguishable in Play Console; no conflicts.
Build Script: flutter build appbundle (not build_release.sh)¶
Decision: Use flutter build appbundle --release directly instead of scripts/build_release.sh for speed.
Why: build_release.sh runs flutter analyze which can take 2+ minutes and sometimes hangs. Direct command is faster.
iOS¶
No App Store Distribution Certificate Yet¶
Decision: iOS TestFlight blocked until Apple Developer Program enrollment (€99/year).
Why: Distribution certs require paid enrollment.
Status: Guide written at docs/apple-developer-testflight-setup.md. Team ID SAF6DV6MH7 is in Xcode.
Development Install: flutter run --release¶
Decision: For device testing before enrollment, use flutter run --release --device-id=00008030-0002196E11E3402E.
Why: Development signing works without paid cert for personal device testing.
App Architecture¶
State Management: Riverpod 2.x¶
Decision: Riverpod (not BLoC, not Provider). Why: Compile-time safety, no BuildContext, cleaner than BLoC for this scale.
Local Storage: Hive¶
Decision: Hive for all local persistence (rides, horses, settings).
Why: Fast, no-SQL, Flutter-native, works offline.
Rule: New Hive fields must use positional field reading with backward-compatible defaults (check numOfFields or use safe defaults for missing fields).
Navigation: go_router 14.x¶
Decision: go_router with StatefulShellRoute.indexedStack for bottom nav.
Why: Deep linking, named routes, type-safe, maintained by Flutter team.
Constraint: Bottom nav branches are fixed at build time; customization is done by showing a subset of branches in ScaffoldWithBottomNav.
Map: flutter_map + OpenStreetMap tiles¶
Decision: flutter_map (not Google Maps). Why: Free, no API key for basic use, OSM data is horse-routing aware, no Google dependency.
Translations: Custom AppLocalizations (not Flutter gen-l10n)¶
Decision: Single app_localizations.dart file with NL/EN/FR/DE maps.
Why: Simple, no codegen complexity, easy to add/edit strings in one file.
Languages: NL (primary), EN, FR, DE.
Security¶
TamperGuard + Play Integrity¶
Decision: Custom TamperGuard (root/Frida/emulator detection) + Play Integrity API. Why: PRO subscription must be server-verified; prevent sideloaded cracked APKs.
HMAC Request Signing¶
Decision: All API requests signed with HMAC-SHA256 via SecureClient.
Why: Prevents fake clients and replay attacks.
Credentials Never Committed¶
Rule: credentials.env, google-services.json, GoogleService-Info.plist, *.jks, play_service_account.json are in .gitignore and NEVER committed.
Note: All these files live in the workdir — not in ~/Downloads or elsewhere.
Features¶
Calorie Calculation: Gait-time × Weight¶
Decision: Estimate calories from gait-time distribution (walk/trot/canter seconds) multiplied by weight-adjusted base rate. Why: No accelerometer or heart rate sensor required; reasonable approximation for a riding app. Baseline: Rider 70 kg, horse 550 kg. Users set actual weights.
Jump Detection: GPS Altitude¶
Decision: Detect jumps from GPS altitude rise/fall pattern (≥0.4 m rise, descent within 5s). Why: No IMU/accelerometer dependency; works with phone GPS. Limitation: Less accurate than accelerometer; GPS altitude noise can cause false positives.
Navigation Autocomplete: Nominatim¶
Decision: Use Nominatim (OSM geocoder) for address autocomplete with 400ms debounce.
Why: Free, no API key, EU data, consistent with the rest of the map stack.
Countries: nl,be,de,fr.
Bottom Nav Customization: Hive-persisted subset¶
Decision: All 6 branches always defined in router; ScaffoldWithBottomNav shows a user-configurable subset (3-5 items).
Why: Can't change router branches at runtime; showing a subset is safe and simple.
Constraints: Home (0) and Settings (5) always visible.
Payments & Pricing¶
Pricing: €10/mnd website, €11.99/mnd stores¶
Decision: Website price = €10.00/mnd (€90/jaar). Store price = €11.99/mnd (€109.99/jaar) assuming Apple Small Business Programme (15% commission).
Why: Website has 0% commission → cheaper. Stores take 15% (Google, Apple SBP) → need higher price to net same amount. Transparent: pricing page explains this clearly.
Applied: equitrail.horse/pro live. AppConstants.proPageUrl set.
Apple Small Business Programme: assume 15%¶
Decision: Assume Apple SBP approval (15% commission) for all pricing and planning. Why: EquiTrail is new, revenue < $1M → almost certainly qualifies. Re-qualify annually. Action: Apply at developer.apple.com/app-store/small-business-program/ when enrolling as Apple Developer. Fallback: If not approved, standard 30% → price would be €14.99/mnd.
Website deploy: FTP only (SFTP does NOT work)¶
Decision: Use plain FTP (port 21) for all website deployments to equitrail.horse (Plesk).
Why: SFTP gives connection timeouts on this Plesk server. FTP with set ftp:ssl-force false works.
Command: lftp ftp://equitrail.horse -u "$WEBSITE_FTP_USER,$WEBSITE_FTP_PASS" -e "set ftp:ssl-force false; put FILE -o /httpdocs/FILE; quit"
Credentials: WEBSITE_FTP_USER and WEBSITE_FTP_PASS in credentials.env.
Website payments via Stripe (before Play/App Store)¶
Decision: Website Stripe integration (#83) is built before Google Play Billing (#84) or App Store IAP (#85).
Why: No store dependency, no Apple enrollment needed, full revenue to EquiTrail, faster to ship.
Needs: Stripe account + publishable/secret keys from user → add to credentials.env.
Discord¶
One bot, one token — RouteHelper merged into main bot¶
Decision: RouteHelper GPX functionality merged into discord_bot/bot.py instead of a separate bot process.
Why: Discord delivers each event to only one connection per token. Two processes with same token compete for events — one misses everything. Solution: one bot handles all functionality.
Applied: v3.4.1+48, SHARE_ROUTES_CH = 1510606612656558100.
Discord features channels: free/preview/exclusive¶
Decision: Three read-only channels in 🎯 FEATURES category:
- #free-features: visible to all Ruiter
- #pro-preview: all Ruiter, teaser + pricing comparison
- #pro-exclusive: PRO + Ambassador + Influencer + Founder only
Why: Lets potential PRO buyers see value without revealing full detail. Natural upsell.
RouteHelper asks for name when GPX has no tag¶
Decision: When a GPX file lacks a <name> element, bot asks uploader in the channel (60s timeout).
Why: "Waypoint" or filename are poor route names. Better UX to prompt.
Applied: bot.wait_for('message', timeout=60) → reply used as name, then deleted.
Health & Medical Disclaimer (#103)¶
Disclaimer scope: all fitness/health data¶
Decision: Add explicit health & medical disclaimer to Terms of Service (all 4 languages), app onboarding (one-time modal), and inline near calorie/jump UI. Also include in Play Store and App Store listing descriptions. Why: EU consumer protection law and the Medical Device Regulation (MDR 2017/745) require that apps which display health-related data clearly disclaim they are not medical devices. Calorie estimates, gait analysis, jump detection and stride data could otherwise be misinterpreted as medical-grade measurements. Applied: terms.html — NL §9, EN §9, DE §3, FR §3. All four languages include: - Not a medical device (MDR) - Data are estimates, not clinically certified - Consult a doctor/vet for real health decisions - EquiTrail accepts no liability for health decisions based on app data - Applies equally to rider AND horse data
Still TODO (#103): - In-app onboarding: one-time modal on first calorie/jump screen visit - Inline note under calorie display in tracking UI: small grey text "Schatting — niet voor medisch gebruik" - Play Store listing: add to "Full description" — standard fitness app caveat - App Store listing: same
Shared PRO Subscription¶
Horse PRO — co-rider discount model¶
Decision: Introduce "Horse PRO" subscription alongside individual "Rider PRO". A Horse PRO subscription is linked to a specific horse and grants PRO benefits to all verified co-riders of that horse at a discounted rate.
Pricing: | Plan | Price | Who benefits | |------|-------|-------------| | Rider PRO (solo) | €10/mnd, €90/jaar | One rider | | Horse PRO (shared) | €15/mnd, €135/jaar | All co-riders on that horse |
Why: Two riders sharing one horse may not both pay full price — realistic in the Dutch market where horses are frequently shared (pensionrijder, half-pension). Horse PRO at 1.5× solo price is fair for 2 riders (€7.50 each equivalent).
Fraud prevention:
1. Horse PRO requires both riders to be listed as co-riders on the horse BEFORE subscription activates.
2. Horse owner subscribes → server writes pro_horse_ids: [horseId] to both rider Firestore docs.
3. If a rider is removed from co-riders list → their PRO access for that horse revokes within 24h.
4. Max 3 co-riders per Horse PRO subscription (prevents "family plan abuse").
5. Same rider cannot hold more than one Horse PRO at a time (prevents stacking discounts).
Backend: Cloud Function onHorseProPurchase validates co-rider list, writes PRO grants, sets expiresAt. App reads isPro from merged rider + horse PRO status.
Implementation track: #102 (backlog). Needs Stripe + Play Billing first (#83, #84).
Monitoring & Ops¶
Dashboard: Python CLI + Web (Flask via Portainer)¶
Decision: Two-tier dashboard — terminal (scripts/dashboard.py) for dev sessions, web (scripts/dashboard_web.py in Docker) for persistent monitoring via Portainer.
Why: Terminal dashboard is instant for ad-hoc checks. Web dashboard persists 24/7 with auto-refresh, accessible from any device on the network.
Port: 8088 (configurable in Portainer via DASHBOARD_PORT).
Service status: Uptime Kuma (not Grafana)¶
Decision: Use Uptime Kuma for service/URL uptime monitoring. Grafana is overkill until we have time-series metrics to graph.
Why: Uptime Kuma is lightweight, visual, already running in user's Portainer. Covers: website, routing server, Firebase, Discord API, Oracle SSH. Config in docker/uptime-kuma-monitors.json.
Future: Add Grafana only when we have Prometheus metrics (e.g., from the routing server or custom app metrics).
Routing server fallback¶
Decision: Add a secondary routing fallback in RoutingService for when GraphHopper is unreachable.
Fallback chain: (1) Oracle GraphHopper → (2) OSRM demo API (temporary, rate-limited) → (3) graceful error with GPS-only mode.
Why: Single routing server is a SPOF for navigation. App works offline but navigation is fully broken when server is down. A degraded fallback is better than an error.
Implementation: #101 (backlog). Detect HTTP timeout → retry with fallback URL → surface degraded banner in UI.
Chat Export¶
Auto-export on Exit: Stop Hook¶
Decision: ~/.claude/settings.json Stop hook runs scripts/auto_export_chat.py on every exit.
Why: User wants permanent record of sessions for context continuity.
Output: docs/chats/chat_YYYYMMDD_HHMM.txt (gitignored).