team18‐iter1‐design - snuhcs-course/swpp-2026-project-team-18 GitHub Wiki
| 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 |
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"]
| 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.
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 |
| 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 |
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.
-
LoginActivityholds sign-in and sign-up;MainActivityholds every other screen;AlarmActivityis separate so it can open over the lock screen even when the app isn't running. - Navigation is a small stack (
AppRoute) held byHomeViewModel, not Navigation Compose. There is no DI framework yet:ApiClientis 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
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
| 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
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.
- 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 aPlanRevisionwhen 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. - The app caches the response in Room and registers with
setAlarmClockonly the alarms the server marks on (alarm_on) for the next 7 days, up to 20. - 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 anAlarmLogrow. - ⑤ reads
GET events/{id}/risk-options, computed from the latest revision's raw route minutes without calling Kakao again. Choosing an optionPATCHestau_override, which recomputes the plan; the app re-registers the alarm. - Ringing, snoozing, dismissing and auto-silencing are written to
AlarmLogQueueon the phone as they happen, then uploaded. - 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.
- 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.
- Departure, arrival (with
entered_at), block durations and alarm logs are queued on disk and uploaded in batches. - Block observations feed the next computation directly. Route corrections update when
train_modelsruns. - ⑧ 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.
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
}
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 |
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/checkruns the sign-up validators and returnsmin_length,letters_and_digitsandmessages. The app calls it on every keystroke, so its throttle is generous: 600 per minute per IP - Event responses carry
alarm_onandis_first_alarm;PATCH events/{id}acceptsalarm_enabled -
places/searchreturns complete road-name or lot-number address matches first (with a nullkakao_place_id), then falls back to place names
Added or changed in 2.0:
-
auth/registeris limited to 10 attempts per hour per IP, successful or not; the 11th gets 429throttledwith a Korean message andRetry-After -
PATCH profilerejectsonboarding_prep_minoutside 5–240 with 400 and the reason inerror.details.onboarding_prep_min; nothing else in that request is saved -
POST eventsandPATCH events/{id}acceptdestination_is_home(not together with aplace) and reject an origin and destination less than 50 m apart (400 inerror.details.destination).alarm_enabled: nullrestores the automatic rule. Responses carrylateness_sensitivityand the plan'stravel_sample_count -
routes/candidateskeepsresultsand adds per-mode listscar(up to 4; one for now),transit(3),walk(1),bicycle(1), plusfastest_key. Each item has aguidesentence; transit items carrybadgesandalt_vehicles, walking itemsstepsandkcal, walking and cycling items apathfor the map preview -
events/{id}/risk-optionsreturnsoptions(safe0.97,normal0.90,gamble0.65, each withalarm_atandprobability),selected_key,auto_adjustedwith its reason,distribution(binsof modeled mass,p50,p90,source: "model") and anunavailable_reasonwhen there is no spread. It never calls Kakao -
reports/weekly?week=YYYY-MM-DD(last week by default) addsby_weekday,late_causes,target_rateandtarget_met,previous_week,deviation,learning(how many plans came from the user's own records), per-eventoutcomes,summaryandsummary_source(llmortemplate) -
alarm-logs/batchtakes{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
τ = 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
totalTimewith 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_countfrom theRouteCorrectionrow it used; the plan now stores it astravel_sample_countand ④ 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
PATCHof the existingtau_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_textrequires 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 infacts. 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_summarysends 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
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_modelspairs each event's first departure and first arrival, drops pairs with poor accuracy, no prediction or an implausible ratio, and writesRouteCorrectionrows: 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.
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.synccancels 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 withalarm_onare registered. -
Alarm switch:
PATCH events/{id}withalarm_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 withKEEP, so opening the app doesn't reset the schedule. -
Uploads: each record gets its
client_uuidwhen 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 noroutes/liveresponse, 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_logsdoes 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 |
| 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) |
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.