Hangangjari System Overview
How collection, API, cache, and widgets share responsibility for the user's first screen
Hangangjari is an iOS app for quickly checking information needed before going to a Hangang park and while being there. From the outside, it looks like a park list, parking status, widgets, and notifications. But the user’s questions are simpler: “Is it okay to go now?”, “Can I bring the car?”, “Would another park be better?”, and “What can I find nearby?”
It is hard to calculate those answers from scratch on every app screen. Parking status changes often, events and notices refresh on different schedules, and forecasts need to be calculated in advance. Widgets must be readable even when the app is not open.
So Hangangjari first separated responsibilities. External data is prepared ahead of time, the API creates responses the app and widget can read immediately, and the client focuses on presentation and interaction.
In one sentence
Hangangjari is not an app that simply relays public and official data as-is. It is a system that turns slow, empty, or late-changing external data into screen-ready values users can trust.
The core decision was not to mix collection, normalization, response generation, and display into one process. External sources can fail and update on different schedules. The app and widgets, however, need to respond quickly at the moment the user sees them.
That is why the user-facing API keeps only the read work needed to build screens. Work that takes time or can fail often, such as collection, forecasting, notifications, and translation, moved into workers and CronJobs.
The app and widgets read first
The first surfaces users see are the app and widgets. Users check the situation there, and if needed, move into a map app. A widget is not a small copy of the app screen. It is a separate runtime that reads values the app has already saved.
flowchart LR
User["User<br/>Checks before and during a visit"] --> Widget["Widget<br/>Short glance"]
User --> App["App<br/>Compare · Detail · Settings"]
Widget --> App
App --> Maps["Map app<br/>Start moving"]
subgraph Device["iOS device"]
App
Widget
Local["In-app cache<br/>Recent screen values"]
Saved["Stored display values<br/>Shared by app and widget"]
end
App <--> Local
App --> Saved
Widget --> Saved
App --> API["Screen-ready API response"]
Widget -.-> API
If the app checks every external source again whenever it draws the screen, the user has to wait. Hangangjari lets the server prepare responses the app can display immediately, while the client stays focused on display and interaction.
The widget moves differently from the app process. It reads stored display values first and asks the API for fresh values only when needed.
External data is prepared ahead of time
If external sources are called one after another after a user request arrives, the app inherits their speed and failures directly. In Hangangjari, workers collect and normalize data first. Postgres keeps reference data and history, while Redis holds current state and summaries that need fast reads.
flowchart LR
Sources["Public/official sources<br/>Parking · Events · Notices · Facilities"] --> Workers["Workers / CronJob<br/>Collect · Normalize · Validate"]
Workers --> Records[("Postgres<br/>Reference data · History")]
Workers --> Fast[("Redis<br/>Fast lookup values")]
Records --> Prepared["Forecasts · Notification candidates<br/>Precomputed"]
Prepared --> Records
Prepared --> Fast
Records --> API["Screen-ready API response"]
Fast --> API
API --> Client["App · Widget"]
This separation prevents “no value” from collapsing into one failure. An external source failure, a stopped worker, an empty fast lookup, and missing reference data in Postgres are different problems. They may look similar on screen, but operationally they have completely different causes.
During incidents, the last checked value is still shown
When there is an incident or delay, the user-facing screen still has to be honest. In Hangangjari, fallback is not a device for hiding failure. It is a device for distinguishing stale values from unavailable information.
flowchart TD
Open["Open app or widget"] --> Saved{"Stored display value<br/>exists?"}
Saved -->|Yes| ShowSaved["Show last checked value<br/>with updated time"]
Saved -->|No| Request["Request a new response"]
Request --> Result{"Can a response<br/>be produced?"}
Result -->|Yes| Fresh["Show fresh value<br/>with refresh state"]
Result -->|Last successful value| Last["Show last successful value<br/>with delay state"]
Result -->|No| Empty["Show unavailable state<br/>without overclaiming"]
In a parking app, an honest response matters as much as a fast response. Showing a 40-minute-old value as current may make the screen fast, but it can move the user in the wrong direction.
That is why home-summary, forecasts, and widget snapshots carry refresh state and source state along with the value itself.
It started with parking, then the roles grew
I did not start by trying to build a large system. The beginning was a small problem: checking remaining spaces in Hangang park parking lots on the web every time was inconvenient.
But once the app is used in real life, parking is not the end. Going to the Han River involves park congestion, events, facilities, notices, weather, public transit, favorites, widgets, and notifications. Users care less about how many sources exist and more about whether they can trust the current screen and move based on it.
The roles therefore split in the order the product needed them.
- The app creates the screen where the user makes a decision now.
- The widget shows values that can be checked briefly before opening the app.
- The API returns screen-ready responses, not DB table shapes.
- Workers handle slow sources, failures, and different update schedules outside the user request time.
- Postgres preserves reference data, history, and audit records.
- Redis handles caches and supporting indexes that can be rebuilt if lost.
Redis exists not only because it is fast. It exists so its contents can be rebuilt from Postgres. Workers exist not merely to run batch jobs, but to separate the user’s request time from the conditions of external sources.
The first screen comes from one response
The most important response for the current app and general park widget is home-summary. It bundles parking, outing information, forecasts, and source-level refresh states into a shape the first screen can read easily.
home-summary is not only an API for reducing call count. It is the response format that lets the app and widget read the same first-screen structure.
If the client calls parking, outing, and forecast APIs separately and combines them locally, each screen can interpret values differently. When the server sends a combined response, the app can focus more on display and interaction.
From the user’s perspective, which API was called matters less than whether parking, events, and forecasts speak from inconsistent timestamps when the app opens. home-summary exists to reduce that difference.
What the following posts cover
| Part | Topic | Key choice |
|---|---|---|
| #1 | iOS client | How app, widget, cache, and deep links continued without breaking context |
| #2 | Backend API | How FastAPI routes stayed focused on screen-ready responses |
| #3 | DB and cache | What stayed in Postgres and what Redis handled |
| #4 | Workers and jobs | Why external sources, forecasts, and push moved outside the API |
| #5 | Data parsing | What had to be recorded to turn official data into app values |
| #6 | Forecasts and data science | How mobile-readable predictions were precomputed |
| #7 | Push notifications | Why sending fewer notifications was harder than sending them |
| #8 | CI/CD and release | How deployment became repeatable in a personal project |
| #9 | Infrastructure and health checks | What to observe when turning a mini PC into an operating environment |
| #10 | Widget snapshots | How app and widgets shared display values |
| #11 | home-summary API | How the first-screen response format helped iOS decoding stability |
| #12 | Collection plans and records | How source-specific refresh cycles and failures were tracked |
| #13 | Redis fallback | How fast responses and honest stale responses were handled together |
| #14 | Parking prediction | Why baseline, confidence, and backtest were separated |
| #15 | Push delivery | Why even suppressed notifications needed audit records |
| #16 | Localization | How source preservation, translation cache, and display data were separated |
| #17 | Privacy and telemetry | What the app chose not to collect while observing product quality |
| #18 | SRE retrospective | How request paths, observability, and backups were handled in a small operation |
Share
No comments yet. You can leave the first one.
Pending review