team18‐iter1‐design - snuhcs-course/swpp-2026-project-team-18 GitHub Wiki

JustInTime — Design Documentation

1. Document Revision History

Version Date Changes
1.0 2026-09-30 Iteration 1. The system as of 0.9.0
1.1 2026-10-07 Updated to 0.10.0 (main 0682182): sign-up checks, per-event alarm switch, address search, UI-driven QA run
1.2 2026-10-08 Reorganized along the course template: external libraries and where each is used, architectural decisions compared with alternatives, app and server class diagrams, database schema with constraints, learning, caching, offline sync and concurrency rules. The verification section was removed; testing belongs in the Testing Documentation
2.0 2026-10-18 Iteration 2 (app 0.11.0, main 8148108). Added the OpenAI-compatible LLM (through OpenRouter) as an external service for the weekly summary, with the decisions around it. New server modules: risk options, plan history, weekly summary and LLM client, request log middleware. New tables PlanRevision, AlarmLog, ApiRequestLog, WeeklySummary, LlmCall and new columns Event.destination_is_home, TripObservation.entered_at, AlarmPlan.travel_sample_count. New endpoints GET events/{id}/risk-options and POST alarm-logs/batch; route candidates grouped by mode. Arrival on the first fix within 100 m; same-day switch reset on manual creation. New app screens (⑤ options, ⑬-e route map, ⑨ day detail) and the alarm log queue. Logging and retention rules

2. System Design

2.1 System Architecture

2.1.1 Overview

JustInTime is a client–server system. The Android app follows MVVM: Compose screens observe StateFlow state from ViewModels, ViewModels call repositories, and repositories talk to the server and to local storage. The server is a Django REST API that computes every alarm, stores all data and is the only part that calls external services.

flowchart LR
  subgraph phone [Android app]
    ui["Compose screens<br/>(View)"] -->|"user actions"| vm["ViewModel"]
    vm -->|"StateFlow"| ui
    vm --> repo["Repository<br/>(Model)"]
    repo --> local["Room cache · SharedPreferences"]
    platform["AlarmManager · trip-tracking service · WorkManager"] --> repo
  end
  repo -->|"HTTPS · JSON · JWT"| api["Django + DRF<br/>Render (Docker)"]
  api --> db[("Neon Postgres")]
  api --> kakao["Kakao<br/>routes · places · reverse geocoding · static maps"]
  api --> seoul["Seoul subway · bus<br/>real-time arrivals"]
  api -.->|"weekly figures only"| llm["OpenAI-compatible LLM<br/>(OpenRouter)<br/>weekly summary"]
Loading
Part Stack
App Kotlin, Jetpack Compose, minSdk 34 / targetSdk 37
Server Python, Django 5.2, Django REST Framework, SimpleJWT, gunicorn
Database Neon Postgres — one database for development and deployment; tests use in-memory SQLite
Hosting Render free plan, Docker, Singapore. Sleeps after 15 min idle
CI GitHub Actions: backend tests · app–server contract check · app tests + lint + build

Principles

  • The server computes every alarm and decides which ones ring. The app caches and displays. One formula in one place, so there is never a question of which side is right.
  • The app never calls external APIs. A key shipped in an APK can be extracted, so the server proxies, throttles and budgets calls (Kakao's free quota is 1,000 per day).
  • No movement traces. Only the fix that decided a departure or arrival is stored. The server does not keep the live route or the positions sent while moving.
  • No invented numbers. Without evidence a value stays blank and the screen says why.
  • The LLM only words what was computed. It receives the week's aggregate figures, and a sentence that states any other number is thrown away for a fixed template. Every figure on screen still comes from our own code.

2.1.2 External libraries and services

App

Library Used for Where
Jetpack Compose (BOM) + Material 3 Every screen ui/
AndroidX Lifecycle (ViewModel) + Kotlin coroutines and Flow Screen state as StateFlow; network and disk work off the main thread HomeViewModel, AuthViewModel
Retrofit + Gson converter REST client; JSON to Kotlin data classes data/remote/*Api.kt, ApiClient
OkHttp logging interceptor HTTP log in debug builds, with password fields masked ApiClient
OkHttp interceptor (own) Sends X-App-Version on every request so the server log can tell app versions apart AppVersionInterceptor
Room (+ KSP) Read-only offline cache of events, profile and routine blocks JitDatabase, CacheDao, OfflineCache
WorkManager Observation and alarm-log upload, plan sync every 6 h, route refresh every 15 min background/ (JitWork, three workers)
Google Play services Location (fused provider) Location fixes for trip tracking and "use current location" TripTrackingService, CurrentLocation
Firebase Cloud Messaging Declared only; no messaging service yet (push is planned for group rooms) build.gradle.kts
JUnit 4, kotlinx-coroutines-test JVM unit tests src/test
AndroidX Test, UI Automator Instrumented end-to-end flows on the e2e build (com.swpp.wakeup.qa) src/androidTest

Android platform APIs are used directly, without a wrapper library: AlarmManager.setAlarmClock, full-screen-intent notifications, a foreground service of type location, broadcast receivers for boot, app update and clock changes, and the calendar provider for import.

Server

Library Used for Where
Django 5.2 ORM, migrations, admin, settings all apps
Django REST Framework 3.18 Views and serializers, per-scope throttling, pagination (20 per page), one error format apps/*/views.py, apps/common/errors.py
SimpleJWT 5.3 Access tokens (30 min) and refresh tokens (14 days) accounts/views.py (LoginView), settings
django-cors-headers CORS headers settings
dj-database-url + psycopg 3 DATABASE_URL parsing; Postgres driver with persistent connections (600 s) and health checks settings
python-dotenv Loads backend/.env in development settings
gunicorn WSGI server in the Docker image (2 workers, 60 s timeout; migrations run at start) Dockerfile
whitenoise Static files (admin) in production prod settings
pytest + pytest-django Backend tests apps/*/tests

HTTP calls to Kakao, the Seoul APIs and the LLM use the standard library (urllib) in routing/clients.py, routing/realtime.py and reports/llm.py; there is no LLM SDK. Distributions are our own code (planning/distributions.py), not numpy.

External services

Service Used for Called from
Kakao APIs Route candidates (transit, walk, bicycle, car), place and address search, reverse geocoding, static map images routing/clients.py
Seoul Open Data Real-time subway and bus arrivals shown on route candidates routing/realtime.py
OpenAI-compatible chat completions (OpenRouter, openai/gpt-4.1-mini) The weekly report's plain-language summary. Any compatible endpoint works through OPENAI_BASE_URL, OPENAI_MODEL and OPENAI_API_KEY; with none set, the template is used reports/llm.py, called from reports/summary.py
Neon Postgres database Django ORM
Render Hosting (Docker, free plan) render.yaml
GitHub Actions CI .github/workflows

2.1.3 Architectural decisions

Decision Alternatives considered Why
Native Android (Kotlin, Compose) Flutter, React Native Exact alarms, full-screen intents and re-registration after boot need direct platform access; a plugin layer makes them hard to debug
Client–server, with every alarm computed on the server Computing on the phone One formula in one place. The phone would need the same estimators, route data and learned values, and two copies drift. Cost: unchecking a block needs a round trip (N10)
The server proxies every external API The app calls Kakao directly A key in an APK can be extracted, and only the server can throttle and budget the shared free quota
REST with JSON over HTTPS GraphQL, WebSocket Every interaction is a request and a response on a few resources. The only live data, the position while moving, is sent once a minute, so polling is enough. The free server sleeps when idle, which suits short requests rather than held connections. Push, when needed, is planned through FCM
JWT access and refresh tokens Server sessions with cookies Stateless for a mobile client, and background workers can call the API with the stored token. A network failure during refresh keeps the user signed in (AT-14)
One shared Neon Postgres for development and deployment A local SQLite per developer; Render Postgres Separate databases drifted apart. Render's free database expires after 30 days, and when declared in render.yaml it attached itself and broke sign-in. Tests still use in-memory SQLite
Store only the chosen route's key Store the chosen route's minutes Minutes go stale when timetables change and could be forged; the key is resolved again on every computation
Read-only offline cache Offline editing with later sync The server is the source of truth for alarms, so offline edits would need conflict rules. What matters offline is reading: the event list and why each alarm is set, and the alarms already registered keep ringing
Background work on the phone (WorkManager) and a foreground service for trips Server cron jobs; background location permission Render's free plan has no cron. A foreground service started at dismissal needs no background-location permission
The LLM is called only by the server, sees only aggregate figures, and its sentence is checked; a template is the fallback (2.0) The app calls the LLM; the LLM computes the report; no fallback A key in the APK could be extracted and spent. Titles, places and coordinates are personal (N19, N47). An LLM can state a wrong number fluently, so the numbers come from our code and a sentence with any other number is dropped (N49). With the template, the report never depends on an outside service being up or paid for
An OpenAI-compatible HTTP endpoint, configured by base URL, model and key A provider SDK; a fixed provider One small urllib client works with OpenAI, OpenRouter or a course-provided endpoint; the team key is an OpenRouter key. No new dependency in the image
Arrival on the first valid fix within 100 m (2.0) 50 m for 2 minutes Indoors, accuracy often gets worse than 50 m, those fixes are dropped, and the 2-minute stay never completed, leaving "confirming arrival" on screen. Campuses and apartment complexes are larger than 50 m. Cost: passing within 100 m on the way counts as arriving again, which the 2-minute stay had prevented, and the arrival is recorded at the edge of the radius rather than at the door. The time the radius was entered is stored separately, so both can be measured later
Risk choice stored in the existing tau_override (2.0) A new "risk level" field The plan already computes from τ, and the DB already constrains it to 0.5–0.999; a second field would need its own precedence rules
A manual event resets that day's switches (2.0) Keep every manual switch until the user changes it Decided at the Oct 12 meeting: once an event is added, the day should follow the first-alarm rule again, since earlier manual choices were made for a different set of events. Calendar import is excluded because it runs in bulk and isn't a decision about that day

2.2 Design Details

2.2.1 App components

ui/              Compose screens
  ↑ StateFlow  ↓ actions
HomeViewModel · AuthViewModel
  ↓
data/repository  → data/remote (Retrofit) · data/local (Room cache, SharedPreferences)
domain/model     pure calculations — progress, ETA, lateness outlook, map camera — JVM-tested
alarm/           setAlarmClock, full-screen alarm, re-registration after boot, update, clock change
sensing/         TripTrackingService (foreground, location) and TripGeofence (departure/arrival)
background/      workers — observation and alarm-log upload, plan sync (6 h), route refresh (15 min, events within 3 h)

New in 2.0: ui/alarm/RiskChoiceScreen (⑤ options and distribution), ui/events/RoutePreviewScreen (⑬-e, opened inside ⑬), ui/report/WeeklyReportScreen (rebuilt) and ReportDayScreen (⑨), ui/common/JitTimePickerDialog (the dial shared by ⑮ and ⑫), data/repository/RiskOptionsMapping and ReportMapping (DTO → screen state, so screens never format server values), alarm/AlarmLogQueue and data/remote/AppVersionInterceptor.

  • Dependencies point one way: ui → ViewModel → repository → remote/local. The platform packages use repositories directly because they must run without a screen.
  • LoginActivity holds sign-in and sign-up; MainActivity holds every other screen; AlarmActivity is separate so it can open over the lock screen even when the app isn't running.
  • Navigation is a small stack (AppRoute) held by HomeViewModel, not Navigation Compose. There is no DI framework yet: ApiClient is an object and constructor defaults are the test seam.

Screens, state and data

classDiagram
  class LoginActivity
  class MainActivity
  class AuthViewModel {
    +state: StateFlow~UiState~
    +login()
    +signup()
    +onPasswordChange(value)
  }
  class HomeViewModel {
    +nav: StateFlow~NavState~
    +state: StateFlow~UiState~
    +plan: StateFlow~AlarmPlanView~
    +refresh()
    +setAlarmEnabled(id, enabled)
    +submitAdd()
    +recomputePlan(eventId)
    +loadRiskOptions(eventId)
    +saveRiskOption(eventId, key)
    +onAddDestinationHome()
    +openRoutePreview(key)
    +loadReport(week)
    +openReportDay(date, eventId)
    +markBlockDone(blockId)
    +logout()
  }
  class AuthRepository {
    +register()
    +login()
    +checkPassword()
    +logout()
  }
  class EventRepository {
    +loadHome()
    +createEvent()
    +setAlarmEnabled()
    +loadPlan()
    +recomputePlan()
    +loadRiskOptions()
    +saveRiskOption(tau)
    +routeCandidates()
    +liveRoute()
    +importCalendar()
  }
  class RoutineRepository {
    +blocks()
    +createBlock()
    +updateBlock()
    +eventBlocks()
    +setEventBlocks()
  }
  class ReportRepository {
    +weekly(week)
    +calibration()
  }
  class ReportMapping {
    <<mapping>>
    +toView(WeeklyReportDto)
  }
  class RiskOptionsMapping {
    <<mapping>>
    +toView(RiskOptionsDto)
  }
  class ApiClient {
    <<object>>
    +auth: AuthApi
    +events: EventsApi
    +profile: ProfileApi
    +routines: RoutinesApi
    +observations: ObservationsApi
    +alarmLogs: AlarmLogsApi
    +reports: ReportsApi
    +health: HealthApi
  }
  class TokenStore {
    +save()
    +updateAccess()
    +clear()
  }
  class OfflineCache {
    +saveEvents()
    +events()
    +event()
    +saveProfile()
    +saveBlocks()
    +wipe()
  }
  class JitDatabase {
    <<Room>>
  }
  class CacheDao {
    <<DAO>>
  }
  LoginActivity --> AuthViewModel
  MainActivity --> HomeViewModel : renders nav and state
  AuthViewModel --> AuthRepository
  HomeViewModel --> EventRepository
  HomeViewModel --> RoutineRepository
  HomeViewModel --> ReportRepository
  AuthRepository --> ApiClient
  AuthRepository --> TokenStore
  EventRepository --> ApiClient
  EventRepository --> OfflineCache
  RoutineRepository --> ApiClient
  RoutineRepository --> OfflineCache
  ReportRepository --> ApiClient
  ReportRepository ..> ReportMapping
  EventRepository ..> RiskOptionsMapping
  ApiClient --> TokenStore : interceptor and authenticator
  OfflineCache --> JitDatabase
  JitDatabase --> CacheDao
Loading

Alarm, trip tracking and background work

classDiagram
  class AlarmScheduler {
    +sync(schedules)
    +snooze(schedule, minutes)
    +restoreAll()
    +cancelAll()
    +registered()
  }
  class ScheduledAlarmStore {
    +all()
    +replaceAll(schedules)
    +find(eventId)
  }
  class AlarmReceiver {
    <<BroadcastReceiver>>
  }
  class BootReceiver {
    <<BroadcastReceiver>>
  }
  class AlarmActivity {
    -dismiss()
    -snooze()
  }
  class TripTrackingService {
    <<foreground Service>>
  }
  class TripGeofence {
    +offer(fix)
    +finalizeArrival()
  }
  class TripLiveState {
    <<object>>
    +snapshot: StateFlow
    +arrival: StateFlow
    +liveRoute: StateFlow
  }
  class TripObservationQueue {
    +enqueue()
    +flush()
  }
  class BlockObservationQueue {
    +enqueue()
    +flush()
  }
  class AlarmLogQueue {
    +record(kind, schedule)
    +flush()
  }
  class LiveRouteStore
  class MorningSessionStore
  class JitWork {
    <<object>>
    +requestObservationUpload()
    +ensurePeriodicSync()
    +cancelAll()
  }
  class ObservationUploadWorker {
    <<Worker>>
  }
  class PlanSyncWorker {
    <<Worker, 6 h>>
  }
  class RouteRefreshWorker {
    <<Worker, 15 min>>
  }
  class EventRepository
  class HomeViewModel
  AlarmScheduler --> ScheduledAlarmStore
  AlarmScheduler ..> AlarmReceiver : PendingIntent
  AlarmReceiver --> AlarmActivity : full-screen intent
  BootReceiver --> AlarmScheduler : restoreAll
  AlarmActivity --> AlarmScheduler : snooze
  AlarmActivity --> TripTrackingService : start on dismiss
  AlarmActivity --> MorningSessionStore : start session
  AlarmScheduler --> AlarmLogQueue : alarm time set or changed
  AlarmReceiver --> AlarmLogQueue : fired
  AlarmActivity --> AlarmLogQueue : dismissed, snoozed, silenced
  ObservationUploadWorker --> AlarmLogQueue
  TripTrackingService --> TripGeofence
  TripTrackingService --> TripLiveState : publish
  TripTrackingService --> TripObservationQueue
  TripTrackingService --> LiveRouteStore
  TripTrackingService --> EventRepository : live route every minute
  JitWork --> ObservationUploadWorker
  JitWork --> PlanSyncWorker
  JitWork --> RouteRefreshWorker
  ObservationUploadWorker --> TripObservationQueue
  ObservationUploadWorker --> BlockObservationQueue
  PlanSyncWorker --> EventRepository
  PlanSyncWorker --> AlarmScheduler
  RouteRefreshWorker --> EventRepository
  RouteRefreshWorker --> AlarmScheduler
  RouteRefreshWorker ..> TripLiveState : skips the tracked event
  HomeViewModel ..> TripLiveState : observes
  HomeViewModel --> AlarmScheduler : sync after a fresh load
  HomeViewModel --> BlockObservationQueue : one tap per block
Loading

2.2.2 Server components

Django app Responsibility
accounts User (email login, terms consent), Profile (home, prep time, default τ), sign-up, live password check, JWT
events Event, Place, EventTag, calendar import, alarm switch, the first-alarm-of-the-day rule and its same-day reset; home as destination; place search (addresses first, then place names), static maps, route candidates grouped by mode, live route, risk options
planning AlarmPlan and the alarm computation (compute_and_store), distributions, estimators, risk options, plan history (PlanRevision)
routing Kakao client, Seoul real-time arrivals, RouteCorrection
routines Routine blocks, per-event selection, block observations
observations Departure and arrival observations; alarm log (ring, snooze, dismiss, silence, switch changes)
prediction Learning from observations (manage.py train_models), ModelArtifact
reports Weekly report and calibration, derived from observations; weekly summary (LLM or template), LLM call records
common Error format, service-area checks, API request log middleware, log retention (manage.py purge_logs)

Every view is scoped to the requesting user. Boxes below are modules; their members are the view classes or the main functions.

classDiagram
  class accounts_views {
    <<views>>
    RegisterView
    LoginView
    PasswordCheckView
    MeView
    ProfileView
  }
  class events_views {
    <<views>>
    EventListCreateView
    EventDetailView
    EventRecomputeView
    EventRiskOptionsView
    EventCalendarImportView
    EventTagListView
    PlaceSearchView
    PlaceStaticMapView
    PlaceReverseView
    RouteCandidateView
    RouteLiveView
  }
  class routines_views {
    <<views>>
    RoutineBlockListCreateView
    RoutineBlockDetailView
    EventBlockSelectionView
    BlockObservationBatchView
  }
  class observations_views {
    <<views>>
    ObservationBatchView
    ObservationListView
    AlarmLogBatchView
  }
  class reports_views {
    <<views>>
    WeeklyReportView
    CalibrationView
  }
  class events_alarms {
    <<module>>
    +alarm_states(user)
    +reset_alarm_overrides(event)
  }
  class planning_risk_options {
    <<module>>
    +build_risk_options(event)
  }
  class planning_history {
    <<module>>
    +record_revision()
  }
  class reports_summary {
    <<module>>
    +summary_for(user, report)
    +build_facts()
    +check_text()
    +render_template()
  }
  class reports_llm {
    <<module>>
    +configured()
    +chat()
  }
  class ApiRequestLogMiddleware {
    <<middleware>>
  }
  class planning_services {
    <<module>>
    +compute_and_store(event)
    +recompute_for_user(user)
  }
  class planning_estimators {
    <<module>>
    +estimate_prep()
    +estimate_travel()
    +compute_alarm_math()
  }
  class planning_distributions {
    <<module>>
    Normal
    Empirical
    Mixture
    +convolve()
    +blend()
  }
  class routing_clients {
    <<module>>
    +route_candidates()
    +resolve_route()
    +best_route()
    +search_places()
    +static_map()
    +coord_to_address()
  }
  class routing_realtime {
    <<module>>
    +enrich_candidates()
    +subway_arrivals()
    +bus_arrivals()
  }
  class reports_services {
    <<module>>
    +weekly()
    +calibration()
    +lateness_causes()
  }
  class prediction_learning {
    <<module>>
    +update_route_corrections()
    +build_artifact()
    +save_artifact()
  }
  class train_models {
    <<command>>
  }
  accounts_views --> planning_services : home or prep change recomputes up to 50 events
  events_views --> planning_services : create, patch, recompute, import
  routines_views --> planning_services : PUT event blocks
  events_views --> events_alarms : alarm_on, is_first_alarm
  events_views --> routing_clients : search, static map, reverse, candidates, live route
  planning_services --> routing_clients : resolve_route or best_route
  planning_services --> planning_estimators
  planning_estimators --> planning_distributions
  routing_clients --> routing_realtime : real-time arrivals on candidates
  reports_views --> reports_services
  reports_views --> reports_summary : summary on top of the figures
  reports_summary --> reports_llm : only when configured and under the limits
  events_views --> planning_risk_options : GET risk-options
  events_views --> events_alarms : reset on manual creation
  planning_risk_options --> planning_estimators
  planning_services --> planning_history : every computation
  train_models --> prediction_learning
Loading

ApiRequestLogMiddleware wraps every /api/ request rather than any one view, so it has no edge above. It writes one ApiRequestLog row after the view returns, and a failure to write never changes the response.

2.2.3 Data flow

  1. The app creates or edits an event. The server stores it and runs compute_and_store: route from Kakao → prep and travel distributions → τ-quantile → AlarmPlan, returned with the event. Each computation also appends a PlanRevision when the decision or its raw inputs changed. Listing events never triggers a computation (Kakao quota). A manually created event resets that day's other switches to automatic in the same transaction; calendar import doesn't.
  2. The app caches the response in Room and registers with setAlarmClock only the alarms the server marks on (alarm_on) for the next 7 days, up to 20.
  3. Flipping an alarm switch on Home sends alarm_enabled. The server re-decides which alarm comes first that day; the app reloads the list and re-registers. The server logs the switch change as an AlarmLog row.
  4. ⑤ reads GET events/{id}/risk-options, computed from the latest revision's raw route minutes without calling Kakao again. Choosing an option PATCHes tau_override, which recomputes the plan; the app re-registers the alarm.
  5. Ringing, snoozing, dismissing and auto-silencing are written to AlarmLogQueue on the phone as they happen, then uploaded.
  6. Dismissing the alarm starts the morning session (one tap per block) and trip tracking: 30 s low-power fixes while at home, 10 s high-accuracy fixes in transit.
  7. While moving, the app posts its position every minute and draws the fastest route for the chosen mode from there. The server answers without storing either. The first valid fix within 100 m of the destination is the arrival; it stays on screen after tracking ends.
  8. Departure, arrival (with entered_at), block durations and alarm logs are queued on disk and uploaded in batches.
  9. Block observations feed the next computation directly. Route corrections update when train_models runs.
  10. ⑧ asks GET reports/weekly. The server computes the figures from observations at request time, then adds the summary: a stored LLM sentence for the same figures, a new LLM call if allowed, or the template. ⑨ uses the same response.

2.2.4 Data model

erDiagram
  User ||--|| Profile : has
  User ||--o{ Event : owns
  Place |o--o{ Event : "destination of"
  EventTag |o--o{ Event : "sets tau of"
  Event ||--o| AlarmPlan : "computed into"
  User ||--o{ RoutineBlock : defines
  RoutineBlock |o--o{ RoutineBlock : "precondition of"
  Event ||--o{ EventBlockSelection : uses
  RoutineBlock ||--o{ EventBlockSelection : "chosen in"
  RoutineBlock ||--o{ BlockObservation : "timed by"
  Event |o--o{ BlockObservation : "morning of"
  Event ||--o{ TripObservation : "depart or arrive"
  User |o--o{ ModelArtifact : "personal (none = global)"
  Event ||--o{ PlanRevision : "history of its plan"
  Event |o--o{ AlarmLog : "rang, snoozed, switched"
  User ||--o{ WeeklySummary : "one per week"
  User ||--o{ LlmCall : "counts toward limits"
  User |o--o{ ApiRequestLog : "made (none = anonymous)"

  User {
    bigint id PK
    string email UK
    string password "hash"
    string nickname
    datetime terms_agreed_at
    bool is_active
    datetime date_joined
  }
  Profile {
    bigint id PK
    bigint user_id FK "one per user"
    float home_lat "pair with home_lng"
    float home_lng
    string home_label
    float default_tau "0.90"
    int onboarding_prep_min
    datetime shadow_started_at
    string timezone
  }
  Place {
    bigint id PK
    string name
    float lat
    float lng
    string address
    string kakao_place_id UK "null for plain addresses"
    datetime created_at
  }
  EventTag {
    bigint id PK
    string key UK
    string label
    float default_tau
    string penalty_shape
    int order
  }
  Event {
    bigint id PK
    bigint user_id FK
    bigint place_id FK
    bigint tag_id FK
    string source
    string external_id "calendar id"
    string title
    datetime start_at
    bool alarm_enabled "null = first-alarm rule"
    float tau_override
    string route_key
    bool destination_is_home "resolved from the profile"
    float origin_lat "null = home"
    float origin_lng
    string origin_label
    datetime created_at
    datetime updated_at
  }
  AlarmPlan {
    bigint id PK
    bigint user_id FK
    bigint event_id FK "one per event"
    string status "ok no_home no_place route_failed"
    datetime alarm_at
    datetime depart_by
    datetime arrive_at
    int prep_minutes
    int travel_minutes
    int buffer_minutes
    float tau_used
    int on_time_probability "null = learning"
    string confidence_basis
    string prep_source
    json prep_breakdown
    float total_quantile_minutes
    string travel_mode
    string travel_source
    int travel_sample_count "trips behind the correction"
    string route_summary
    string route_key
    string route_detail
    json route_path
    int route_distance_m
    string alt_route_key
    string alt_route_label
    int alt_faster_minutes
    json alt_route_path
    datetime computed_at
  }
  RoutineBlock {
    bigint id PK
    bigint user_id FK
    bigint precondition_id FK
    string name
    int default_min_minutes
    int default_max_minutes
    string drop_cost
    bool parallelizable
    bool included_by_default
    int order
    datetime created_at
    datetime updated_at
  }
  EventBlockSelection {
    bigint id PK
    bigint event_id FK
    bigint block_id FK
    bool checked
    datetime updated_at
  }
  BlockObservation {
    bigint id PK
    bigint user_id FK
    bigint block_id FK
    bigint event_id FK
    date observed_on
    float duration_minutes
    float slack_minutes "slack at block start"
    bool was_parallel
    string client_uuid
    datetime client_recorded_at
    datetime server_received_at
  }
  TripObservation {
    bigint id PK
    bigint user_id FK
    bigint event_id FK
    string kind "depart or arrive"
    string detector
    datetime observed_at
    float lat
    float lng
    float accuracy_m
    float distance_m
    int dwell_seconds "0 since 2.0"
    datetime entered_at "radius entered"
    string client_uuid
    datetime created_at
  }
  PlanRevision {
    bigint id PK
    bigint user_id FK
    bigint event_id FK
    datetime computed_at
    string trigger "create update recompute import profile blocks"
    string status
    datetime alarm_at
    datetime depart_by
    datetime arrive_at
    int prep_minutes
    int travel_minutes
    int buffer_minutes
    float tau_used
    int on_time_probability
    string travel_source
    int travel_sample_count
    string route_key
    string requested_route_key
    int route_minutes_raw "Kakao minutes before correction"
    datetime event_start_at
  }
  AlarmLog {
    bigint id PK
    bigint user_id FK
    bigint event_id FK "null once deleted"
    string kind "scheduled fired snoozed dismissed silenced enabled disabled auto"
    datetime occurred_at
    datetime alarm_at
    int snooze_minutes
    string source "app or server"
    string client_uuid
    datetime created_at
  }
  WeeklySummary {
    bigint id PK
    bigint user_id FK
    date week_start
    string facts_hash
    json facts "figures sent"
    text text
    string model
    datetime updated_at
  }
  LlmCall {
    bigint id PK
    bigint user_id FK
    date day
    date week_start
    string outcome "pending ok invalid or a failure"
    string model
    string facts_hash
    int duration_ms
    datetime created_at
  }
  ApiRequestLog {
    bigint id PK
    bigint user_id FK
    datetime created_at
    string method
    string route "URL pattern"
    bigint object_id
    int status_code
    int duration_ms
    string app_version
    json query "secrets and coordinates removed"
    json request_body
    json response_body
    string error_code
  }
  RouteCorrection {
    bigint id PK
    string route_signature "empty = global"
    string mode
    int hour_bucket "-1 = all hours"
    string weekday_type
    float factor
    float sd_minutes
    int sample_count
    datetime updated_at
  }
  ModelArtifact {
    bigint id PK
    bigint user_id FK "null = global"
    string version
    json payload
    int sample_count
    bool is_active
    datetime created_at
  }
Loading

User also owns AlarmPlan, BlockObservation, TripObservation, PlanRevision and AlarmLog rows directly (user_id), so every query can be scoped to the requesting user; those edges are left out of the diagram. RouteCorrection has no owner: corrections are per route and shared. The five tables added in 2.0 and the three new columns are all new tables or columns with a database default, so older rows and older app versions keep working (N37).

Rules beyond the columns

Table Constraints and rules
Profile Home coordinates both set or both empty, inside Korea (validated on save); τ 0.5–0.999. A new home or prep time recomputes up to 50 future events
Place Shared across users; latitude and longitude in range; kakao_place_id unique when set
EventTag key unique; τ 0.5–0.999. class 0.90 · exam 0.99 · presentation 0.98 · train/flight 0.99 · part-time 0.95 · meetup 0.85
Event (user, external_id) unique when imported from a calendar; τ override empty or 0.5–0.999; origin coordinates both or neither; index (user, start_at). τ = override → tag → profile. Only the chosen route key is stored, never its minutes. alarm_on and is_first_alarm are computed per response, not stored. destination_is_home requires an empty place; home is read from the profile at each computation, so no copy of it is stored. Origin and destination must be at least 50 m apart, both inside Korea. lateness_sensitivity (high ≥ 0.95, normal ≥ 0.85, else low) is derived from τ per response
AlarmPlan One per event. Status ok requires the times and minutes; index (user, alarm_at)
RoutineBlock (user, name) unique; min ≤ max ≤ 480; a block can't be its own precondition, and precondition cycles are rejected
EventBlockSelection (event, block) unique
BlockObservation (user, client_uuid) unique, so resends add no rows; duration 0–480
TripObservation (user, client_uuid) unique; accuracy and distance ≥ 0. One fix per decision, no trace. Since 2.0 an arrival has entered_at == observed_at and dwell_seconds == 0; dwell_confirmed stays only to read older 2-minute rows
PlanRevision Appended by compute_and_store when the decision fields, the requested route key, the raw route minutes or the sample count changed; index (event, computed_at). Risk options read the latest revision's raw minutes and refuse when it no longer matches the plan. Learning and the report don't read it yet
AlarmLog (user, client_uuid) unique, so resends add no rows; index (user, occurred_at) and (event, kind). The event is set to null when it is deleted, and a deleted event in a batch doesn't reject the rest
WeeklySummary (user, week_start) unique. Stores only the LLM's sentence and the figures it was given; the report's figures are always recomputed
LlmCall One row per call, written as pending before calling and updated with the outcome; indexes (day) and (user, day). The daily limits count these rows. Kept 90 days
ApiRequestLog One row per /api/ request that matched a URL pattern; anonymous requests at most 2,000 a day per process. Passwords, tokens and coordinates are removed, routes/live responses aren't kept. Kept 30 days
RouteCorrection (route_signature, mode, hour_bucket, weekday_type) unique; factor 0.25–4.0; sd ≥ 0. Written by train_models
ModelArtifact (user, version) unique; at most one active artifact per user and one active global artifact. Written by train_models; no endpoint serves it yet

On the phone

Store Kind Contents
cached_event Room (jit_cache.db) id, ownerEmail, startAtEpochSecond, payload (the server's event JSON), cachedAt; index (ownerEmail, startAtEpochSecond)
cached_profile Room ownerEmail, payload, cachedAt
cached_block Room id, ownerEmail, sortOrder, payload, cachedAt; index (ownerEmail, sortOrder)
TokenStore SharedPreferences jit_auth Access and refresh tokens, email, nickname. Plain text for now (N23)
ScheduledAlarmStore SharedPreferences The alarms registered with AlarmManager, read again after a reboot
TripObservationQueue SharedPreferences Departure and arrival records waiting for upload, at most 100
BlockObservationQueue SharedPreferences Block records waiting for upload, at most 200
AlarmLogQueue SharedPreferences jit_alarm_log_queue Alarm events (set, fired, snoozed, dismissed, silenced) waiting for upload, at most 100
MorningSessionStore SharedPreferences The morning session in progress; stale after 6 h
LiveRouteStore SharedPreferences The latest live route while moving; ignored once older than 5 min, excluded from backups

2.2.5 API

Everything is under /api/, with Authorization: Bearer <access> except health, password check, sign-up and token endpoints.

Area Endpoints
Auth POST auth/password/check · POST auth/register · POST auth/token · POST auth/token/refresh · GET auth/me
Profile GET PATCH PUT profile
Events GET POST events · GET PATCH DELETE events/{id} · POST events/{id}/recompute · GET events/{id}/risk-options · POST events/import · GET events/tags · GET PUT events/{id}/blocks
Places GET places/search · GET places/staticmap · GET places/reverse
Routes GET routes/candidates · POST routes/live (current position, while moving)
Observations POST observations/batch · GET observations · POST alarm-logs/batch
Routines GET POST routines/blocks · GET PATCH DELETE routines/blocks/{id} · POST routines/observations/batch
Reports GET reports/weekly · GET reports/calibration
Health GET health — version and real-time availability

Errors are {"error": {"code", "message", "details"}}. Another user's id returns 404, not 403. Batch uploads are idempotent on (user, client_uuid) because the app resends records queued while offline.

  • auth/password/check runs the sign-up validators and returns min_length, letters_and_digits and messages. The app calls it on every keystroke, so its throttle is generous: 600 per minute per IP
  • Event responses carry alarm_on and is_first_alarm; PATCH events/{id} accepts alarm_enabled
  • places/search returns complete road-name or lot-number address matches first (with a null kakao_place_id), then falls back to place names

Added or changed in 2.0:

  • auth/register is limited to 10 attempts per hour per IP, successful or not; the 11th gets 429 throttled with a Korean message and Retry-After
  • PATCH profile rejects onboarding_prep_min outside 5–240 with 400 and the reason in error.details.onboarding_prep_min; nothing else in that request is saved
  • POST events and PATCH events/{id} accept destination_is_home (not together with a place) and reject an origin and destination less than 50 m apart (400 in error.details.destination). alarm_enabled: null restores the automatic rule. Responses carry lateness_sensitivity and the plan's travel_sample_count
  • routes/candidates keeps results and adds per-mode lists car (up to 4; one for now), transit (3), walk (1), bicycle (1), plus fastest_key. Each item has a guide sentence; transit items carry badges and alt_vehicles, walking items steps and kcal, walking and cycling items a path for the map preview
  • events/{id}/risk-options returns options (safe 0.97, normal 0.90, gamble 0.65, each with alarm_at and probability), selected_key, auto_adjusted with its reason, distribution (bins of modeled mass, p50, p90, source: "model") and an unavailable_reason when there is no spread. It never calls Kakao
  • reports/weekly?week=YYYY-MM-DD (last week by default) adds by_weekday, late_causes, target_rate and target_met, previous_week, deviation, learning (how many plans came from the user's own records), per-event outcomes, summary and summary_source (llm or template)
  • alarm-logs/batch takes {event, kind, occurred_at, alarm_at, snooze_minutes, client_uuid} records with the app's kinds (scheduled, fired, snoozed, dismissed, silenced); the switch kinds are written by the server. Idempotent on (user, client_uuid) like the other batches
  • Every request may send X-App-Version; it is stored in the request log

2.2.6 Alarm computation and learning

τ         = event override → event type → profile (0.90)
prep      = checked blocks: declared range → sd = (max − min) / 4, blended with observations by n / (n + 5)
            else the onboarding answer, else 30 min. None if the event has its own origin
travel    = Kakao minutes × RouteCorrection factor, sd from the correction (0 if none)
q         = τ-quantile of (prep ⊕ travel)
alarm     = start − 10 min buffer − q
P(on time) = cdf(q + 10) only when prep and travel both have variance, else null + reason
  • τ is applied once, after combining the distributions. Adding two 0.9 quantiles would mean about 0.99 joint confidence and wake the user far too early.
  • Normal + Normal is combined analytically; anything else uses Monte Carlo with 2,000 samples and a fixed seed, so the same input always gives the same alarm.
  • Kakao returns one totalTime with no variance. Until route corrections carry a spread, most plans have no probability, and the screen says why instead of showing a number.
  • Which alarms ring is decided on the server too. For each day (start date in the user's time zone), the earliest alarm is on unless switched off; later ones ring only when switched on. Switching the first one off hands the role to the next. Since 2.0, manually adding an event sets that day's other switches back to automatic (null). Plans are not recomputed for this; only the switches change, and each change is logged as auto.
  • Travel correction count (2.0). The estimator already took a sample_count from the RouteCorrection row it used; the plan now stores it as travel_sample_count and ④ shows "이동 기록 N회로 보정" (corrected by N trips). It is 0 with no correction or a failed lookup.

Risk options (2.0). build_risk_options reuses the alarm computation instead of a second formula:

plan must be ok, and the latest PlanRevision must match it (else: "recompute first")
prep, travel = estimate_components(event, raw Kakao minutes from that revision)
total = prep ⊕ travel
if total.q99 ≤ total.q50: no spread → unavailable_reason, no options
for τ in safe 0.97, normal 0.90, gamble 0.65:
    alarm_at = start − (q_τ(total) + 10 min buffer),  probability as in compute_alarm_math
distribution = total's CDF in about 10 equal bins from 0, plus p50 and p90
  • The raw minutes come from the stored revision, so opening ⑤ never calls Kakao.
  • The distribution is a modeled proportion, labeled "모형 계산" (modeled), never a count of trips. A τ outside the three presets (an exam's 0.99) is shown as in use with no option selected until the user picks one.
  • Choosing an option is a PATCH of the existing tau_override; no new field was needed.

Weekly summary (2.0). reports/summary.py, on top of the figures that reports/services.py computes:

facts = build_facts(report)            fixed keys, aggregate numbers only
template = render_template(facts)      always built first
if no LLM configured → template
if WeeklySummary(user, week) has the same hash (prompt version, model, facts) → stored sentence
if the week is still running, has fewer than 3 arrivals, or is older than 12 weeks → template
if the LLM is backing off, or these facts already failed the check → template
if user ≥ 30 calls today or server ≥ 100 calls today → template
LlmCall(pending) → chat(system prompt, facts) within 8 s → check_text(text, facts)
pass → store and return (llm);  fail → template, outcome recorded
  • check_text requires 40–400 characters with Korean text, and rejects the word "학습" (learning), URLs, numbers spelled out in words, dates and clock times. Every number in the sentence must be one of the figures in facts. The template passes the same check (tested).
  • A thin or unfinished week gets the template on purpose: an LLM writing "target met" over a week the screen marks as too thin would contradict the screen.
  • Back-off after a failure: 10 min for a timeout, network error, 429 or 5xx; 60 min for a bad base URL or another 4xx. A sentence that failed the check isn't requested again until the figures change.
  • Changing the prompt bumps PROMPT_VERSION, which changes the hash and regenerates stored sentences.
  • manage.py probe_report_summary sends one sample request and prints the model, host, latency and whether the check passed, never the key.
classDiagram
  class Distribution {
    <<abstract>>
    +mean
    +quantile(tau)
    +cdf(minutes)
    +sample(rng)
  }
  class Normal
  class Empirical
  class Mixture
  class PrepEstimate {
    +distribution: Distribution
    +block_count
  }
  class TravelEstimate {
    +distribution: Distribution
    +raw_minutes
    +factor
  }
  class AlarmMath {
    +prep_minutes
    +travel_minutes
    +buffer_minutes
    +total_minutes
  }
  Distribution <|-- Normal
  Distribution <|-- Empirical
  Distribution <|-- Mixture
  PrepEstimate --> Distribution
  TravelEstimate --> Distribution
  PrepEstimate ..> AlarmMath : compute_alarm_math
  TravelEstimate ..> AlarmMath : compute_alarm_math
Loading

Learning

  • Preparation is learned per user. Each block observation (duration, and slack at the block's start) is used by the next computation directly: the block's declared range is the prior, and observations take weight n / (n + 5).
  • Travel is learned per route, not per user: a line's morning delay is the same for everyone. manage.py train_models pairs each event's first departure and first arrival, drops pairs with poor accuracy, no prediction or an implausible ratio, and writes RouteCorrection rows: factor = mean of actual ÷ predicted, sd = spread left after the correction. Rows are kept per route, mode, hour and weekday type; per route, mode and weekday type; per mode across all routes; and one overall row. Route rows need a minimum number of samples. The estimator takes the most specific row that exists and blends a route's row with the global one by n / (n + 5). Training runs by hand for now, since the free plan has no cron.
  • ModelArtifact is a parameters-only JSON snapshot (global and per user) written by train_models. It is meant to let the app recompute locally when a block is toggled (N10) and while offline, without shipping a model file. No endpoint serves it yet.

2.2.7 Implementation decisions

Caching

Where What Rule
App, Room Events, profile, routine blocks Only successful responses are cached, per account (email trimmed and lowercased); failures never are. The cache is never the source of truth: a deleted event leaves it, logout wipes it. Home and ④ fall back to it with its age ("12분 전 정보", saved 12 min ago)
App, disk Latest live route Lets ④ show the route at once when it comes back while the service keeps tracking; ignored after 5 min
Server, cache Static map images 1 h
Server, cache Seoul real-time arrivals Subway 20 s, bus 15 s, station lookups 24 h, failed lookups 60 s
Server, cache Static-map daily budget Counts upstream calls, at most 900 a day (N12)
Server, database Weekly summary sentence One per user and week, reused while the facts hash (prompt version, model, figures) is the same; the figures themselves are never cached
Server, cache LLM back-off After a failure the LLM isn't called for 10 or 60 min; a sentence that failed the check isn't requested again for 12 weeks unless the figures change

In production the server cache is Django's file-based cache in /tmp, shared by the two gunicorn workers of one instance and reset on redeploy (N42). There is no route cache yet (N8, N9).

Offline, sync and uploads

  • Alarm registration replaces the whole set. AlarmScheduler.sync cancels what is registered and registers the given alarms (future, within 7 days, at most 20). It runs only after a successful network load, never from cached data (networkSucceeded && !fromCache), because an empty or stale list would cancel alarms. Only events with alarm_on are registered.
  • Alarm switch: PATCH events/{id} with alarm_enabled → reload the list → re-register. Switches can't be changed while offline.
  • Background: PlanSyncWorker (6 h) reloads plans and re-registers, so the 7-day window keeps moving even if the app isn't opened. RouteRefreshWorker (15 min) checks the registered alarms on the device first and recomputes only events whose alarm is within 3 h, at most 2 per run, skipping the event being tracked. Both need a network connection and back off exponentially from 30 s; periodic work is enqueued with KEEP, so opening the app doesn't reset the schedule.
  • Uploads: each record gets its client_uuid when it is created (block records: blk-<eventId>-<blockId>-<startedAtMillis>), so a resend is recognized. The two queues are sent separately. Permanent failures (bad format, deleted event) are dropped; temporary ones are retried up to 5 times, then on the next run or app launch. One unique upload job drains both queues.
  • Account isolation: local stores are stamped with the owner and discard another owner's data on read. Logout wipes in order: stop tracking → cancel alarms → trip queue → block queue → morning session → live route → in-memory trip state, then tokens and the Room cache, then cancels the workers.

Logging and retention (2.0)

  • What is stored to study later: every plan change (PlanRevision), every alarm event (AlarmLog), every API request (ApiRequestLog) and every LLM call (LlmCall). In Django admin, an event's page shows its plan history, alarm log and trip observations read-only.
  • The request log strips passwords, tokens, secrets and every coordinate field, keeps only the count of point lists ([coords:12]), and keeps no routes/live response, so it can't rebuild a movement trace (N19).
  • A failure to write any of these logs never changes the response or stops the alarm.
  • Retention: request logs 30 days, LLM calls 90 days. With no cron, each server process purges once a day on its first request; manage.py purge_logs does the same by hand.

Concurrency

Side Situation Handling
App Service and screens both need the trip The foreground service owns location fixes and the once-a-minute live-route call; screens only observe TripLiveState (in memory) and read LiveRouteStore
App Several requests get 401 at once TokenRefreshAuthenticator is synchronized, so one refresh serves them all. 400/401/403 on refresh signs out; 5xx, 404, 429 or a network error keeps the tokens
App The server is waking up ServerWarmup holds a mutex so concurrent callers share one wake-up (waits up to 70 s; skipped if a request succeeded in the last 60 s). ColdStartRetryInterceptor retries GET only, on 502/503/504 or a network error, up to 3 tries 1 s then 2 s apart; writes are never retried
Server Two computations for one event compute_and_store runs in a transaction and writes the single AlarmPlan with update_or_create
Server Duplicate or concurrent uploads Batches run in a transaction with get_or_create on (user, client_uuid); the first record wins and the reply is {accepted, duplicated}. Calendar import and the block-selection PUT are transactions too
Server Bursts and quota Per-user throttles: routes 120/h (also on creating events), static maps 120/h, live route 80/h, observation uploads 600/h; per IP: password check 600/min, sign-up 10/h
Server A new event and the same day's switches reset_alarm_overrides runs inside the creation transaction and locks that day's rows (select_for_update); if the plan computation fails, the event and the reset are both rolled back
Server LLM limits across workers and restarts Each call first inserts an LlmCall row (pending), and the limits count today's rows, so they hold across both workers and survive a sleeping or redeployed server, which a /tmp counter wouldn't
Server Two workers share the static-map budget The file cache is shared but its increments aren't atomic, so the count is approximate (N42)

In-house versus third-party

Need Chosen Instead of Why
Maps Server-rendered Kakao static map images with our own pan and zoom Kakao Map SDK No app key or signing-key hash in the APK; the server caches images and budgets calls
Distributions planning/distributions.py (Normal, Empirical, Mixture) numpy A small amount of code; no extra dependency or image size
Learned parameters JSON artifact A TensorFlow Lite model Only a few parameters per route and block; the same formulas can run anywhere and stay unit-testable
Navigation Own stack (AppRoute) Navigation Compose Few screens with a near-linear stack; no routes, argument serialization or deep links needed yet. To be revisited when tabs arrive
Dependency injection Manual (ApiClient object, constructor defaults) Hilt Kept small for Iteration 1; Hilt or an app container is to be decided with the design-pattern refactoring
Password rules Server validators, checked while typing Rules in the app One set of rules; the app's copy had drifted

2.2.8 What we tried

Tried Result Now
setExact, setExactAndAllowWhileIdle Delayed under Doze; the latter has a 9-min floor setAlarmClock — fires through Doze and shows the system alarm icon
startActivity alone for the ringing screen Blocked by background-launch limits Full-screen-intent notification plus startActivity
Kakao routes as a source of variance Measured: one point estimate, no delay, headway or interval The spread must come from our own observations
Kakao bicycle routes for slope and bike-lane share Not in the response (distance, time, guidance, 2-D points). Only a route_mode option: bike roads first / shortest / comfortable Slope dropped from the design; the bike-lane cell stays empty until the server can estimate it
Render Postgres Free DB expires in 30 days; declared in render.yaml it attached itself and broke sign-in while health stayed 200 Neon Postgres, direct URL (the pooler breaks migrations)
Local SQLite for development Two databases drifted apart One Neon DB; tests use in-memory SQLite
Storing the chosen route's minutes Forgeable, stale when timetables change Store the route key, re-resolve on every computation
Computing block toggles on the phone Two formulas, unclear which is right Server round trip — costs N10
Departure = leaving a 150 m radius Missed on campuses and large apartment complexes Also "kept moving": 80 m of movement spread over at least 60 s
Arrival = entering 50 m Passing by counted as arriving; a 10 m radius never opened with urban GPS error 50 m for 2 minutes in Iteration 1, replaced in 2.0 (next rows)
Server cron for retraining and route refresh None on Render's free plan Route refresh runs as an on-device worker; train_models still runs by hand
Gson with Kotlin default values Missing JSON keys became null and crashed alarm dismissal after an upgrade Disk JSON is normalized; CI checks app field names against real server responses
numpy for distributions Extra dependency and image size Own distributions.py (Normal, Empirical, Mixture)
Password rules checked in the app The app's rules drifted from the server's validators The server checks while typing, with the same validators as sign-up
Deciding the day's first alarm on the phone Lost on reinstall or a new phone; Home and the background workers could disagree The server stores the switch and returns alarm_on / is_first_alarm
Sending home's coordinates as the origin The server read it as "not leaving from home" and dropped the prep routine Send no origin when it is home
Place-name search for a home address Road-name and lot-number addresses weren't found Complete addresses first, place names as the fallback
Arrival state tied to the tracking service The arrival vanished from the screen when the service stopped Arrival kept apart from the live position; cleared on new tracking and logout
Arrival = 50 m for 2 minutes (Iteration 1) Indoors, fixes worse than 50 m were dropped and the stay never completed; users stayed on "confirming arrival" First valid fix within 100 m, with entered_at stored (2.0)
Prep time with ±5 buttons, a number field and preset chips Too much on one screen for one number A time dial read as hours:minutes, shared with the event start time (2.0)
"Learning" in place of an unknown probability Promised a number that waiting alone wouldn't produce; travel spread needs trip records Say why it is unknown and what would change it (2.0)
One list of route candidates capped at 6 in total The modes shared one cap, and the screen had no per-mode limits to work with Per-mode lists with their own limits and fastest_key; results kept for older apps (2.0)
Counting LLM calls in the /tmp file cache Found in review: the count resets whenever the free server sleeps or redeploys, so the daily limit wouldn't hold Count LlmCall rows in the database (2.0)
Rejecting a whole alarm-log batch when one of its events no longer exists Found in review: the app would drop the other events' ring, snooze and dismiss records in that batch; the same rule is a suspected cause of lost trip records Records of a missing event are stored with a null event; another user's event still makes the batch 404 (2.0)

2.3 Design Patterns

To be documented in Iteration 5, as the course schedules. Already visible in the code: Repository (data/repository), Observer through StateFlow (ViewModels and TripLiveState to screens), and polymorphic distributions (Distribution → Normal, Empirical, Mixture). Planned in the full specification: Strategy for swapping the prep estimator, Chain of Responsibility for the alarm decision pipeline, and Factory for replan alternatives. Added in Iteration 2 and worth documenting then: the weekly summary's LLM-or-template choice behaves like a Strategy with a guaranteed fallback, and the DTO → screen-state mappings (ReportMapping, RiskOptionsMapping) act as Adapters.

⚠️ **GitHub.com Fallback** ⚠️