Skip to main content

Health Dashboard, 7. deo: aplikacija koja je prestala da računa

Sedmi deo mog build log-a za Health Dashboard: kako je iOS klijent izrastao iz čistog prenosioca podataka u read-only viewer, zašto je svaki refactor brisao klijentsku logiku umesto da je dodaje i kako naučiti reći „ne“ još jednom malom pravilu na uređaju

· 16 мин читања

Prvih šest delova ove serije bilo je o tome kako server uči da ima sopstvena mišljenja. Ingestion gates, identitet izvora, eligibility za sub-scores, granice verzija formula, validation rubrics — svaki sloj zaslužio je pravo da izda broj. Kada je taj posao bio završen, sa druge strane žice otvorilo se novo pitanje: šta iOS klijentu uopšte sme da radi sa tim brojevima?

Odgovor je ispao uži nego što sam očekivao na početku. Najzanimljiviji deo izgradnje mobilne aplikacije nije bio ono što je u nju dodato. Bio je ono što je iz nje iznova i iznova brisano, dok je server učio da svaku stvar radi bolje.

Ovaj deo je build log tog oduzimanja.

Šta je aplikacija bila na početku

Prvi commit-i health-sync nisu napravili ništa što liči na dashboard. Početni UI imao je tri ekrana: Settings tab za server URL i API key, Status tab sa tekstom „Last sync: 2 min ago / Failed (3 retries)“ i TabView za prebacivanje između njih.

0db7c78 feat: initial UI — DesignSystem, SettingsView, StatusView, TabView

I to je bilo sve. Aplikacija je bila predajnik. Pritisnuti „Sync Now“ ili sačekati HKObserverQuery wakeup, pa gledati kako se status red ažurira. Da stvarno vidim faze sna ili readiness history, otvarao sam health.dzarlax.dev u Safari-ju i gledao web dashboard.

To je bila namerno mala površina. U 2. delu detaljno je opisana ingestion strana. U tom trenutku otvorena pitanja su bila: „da li per-segment sleep records preživljavaju novi pipeline?“ i „da li resetovanja HKQueryAnchor1 posle svakog neuspeha dupliraju podatke?“ — a ne „kako treba da izgleda Activity tab?“. Graditi UI za podatke kojima sam tek učio da verujem bilo bi prerano.

To je takođe značilo da pre dodavanja novih ekrana moram da donesem pravu odluku: da li će iOS aplikacija uopšte biti viewer, ili je Safari zauvek dovoljan?

Odluka: nativni viewer, ne „otvori Safari“

Pošten argument za „samo koristi web dashboard“ je jednostavan: radi, server već govori JSON, Safari je dovoljno sposoban. U većini slučajeva taj argument pobeđuje.

Ovde nije pobedio iz četiri razloga, po redosledu stvarne važnosti:

  1. Offline-friendly čitanje. Web dashboard traži round-trip pri svakom učitavanju stranice. Nativna aplikacija može da kešira poslednji odgovor /api/health-briefing i odmah ga prikaže kada dodirnem ikonu u 06:50 sa jednom crticom signala. Isto važi u avionu i na mestima gde je SIM u roaming-u, a HTTPS do kućnog servera radi na prekide.

  2. Taktilna ravnopravnost sa ostatkom HealthKit-a. Na iOS-u health data živi u aplikacijama sa nativnim grafikonima, swipe gestures i sistemskim sharing-om. Web stranica u Safari-ju ne deluje kao deo tog ekosistema. To je uglavnom estetika, ali estetika je važna na uređaju koji proveravaš svakog jutra.

  3. Widgets i lock-screen surfaces. „Last sync · 142 pts“ na zaključanom ekranu moguće je samo kroz pravi app target. Isto važi za App Intents2, na primer SyncNowIntent za Shortcuts / Siri.

  4. Background sync. Putanja BGProcessingTask3 svakako traži native app target iz ingestion razloga, nevezano za prikaz. Kada taj target već postoji, ne prikazivati u njemu ništa deluje kao propušten resurs.

Commit u kom je aplikacija prestala da bude čist predajnik i postala viewer jedna je linija u git log-u:

b9f538e feat: native read-only dashboard, en/ru/sr localization

U tom PR-u pojavilo se pet tabova: Today, Sleep, Trends, Metrics, Settings. Imena su usklađena sa serverom. To su iste sekcije koje prikazuje web dashboard, sa istim JSON endpoints iza njih.

Pravilo koje je olakšalo sve ostalo

Kada praviš drugog klijenta preko API-ja koji sam poseduješ, gravitacija uvek vuče ka dupliranju server logic. Podaci su već na uređaju, oblik grafikona je vidljiv, jasno je kojom bojom treba prikazati status „good readiness“. Nekoliko linija Swift-a — dodati helper readinessColor(for: Int) pored view-a.

Nisam napisao taj helper. Umesto toga sam dodao polje u odgovor servera:

{
  "readiness_today": 78,
  "readiness_status": "good",
  "readiness_color_token": "ds.semantic.positive"
}

Pravilo koje je sva naredna rešenja učinilo jednostavnijim: bez business logic na klijentu. Nema readiness scoring-a. Nema agregacije. Nema source priority. Nema sleep dedup. Nema „ako je vrednost > 70, prikaži zeleno“. Nema tabela prevoda za display names metrika. Nema interpretacije koju server već ume da uradi.

Klijent radi četiri stvari:

  • Renderuje charts i lists iz server JSON-a.

  • Upravlja navigacijom između ekrana.

  • Upravlja local-only stvarima: server URL, API key u Keychain-u, sync history — poslednjih 50 zapisa, retry queue za failed payloads.

  • Razgovara sa HealthKit-om na ingestion strani — teritorija 2. dela, posebna uloga, arhitektonski odvojena od viewer-a.

Sve ostalo — svaki sloj interpretacije, svako formatting rule koje teoretski može da se razlikuje po tenant-u ili formula version — živi na serveru. Kada dođu nove metrike ili novi copy, app update nije potreban.

To pravilo zvuči strogo na papiru. U praksi je iz projekta uklonilo cele klase bagova. Web dashboard i iOS app ne mogu da se raziđu oko toga šta znači „good readiness“, jer nijedan od njih nije izvor te definicije.

ServerClient je @MainActor final class, a ne actor

Prvi netrivijalni Swift 6 zid u koji sam udario bio je tip ServerClient. Refleksno sam ga napisao kao actor4. Concurrent network requests, async/await svuda — delovalo je kao očigledan izbor. Onda se ništa nije kompajliralo.

Projekat se gradi sa Swift 6 strict concurrency (-strict-concurrency=complete). Default actor isolation za projekat je MainActor. To znači da je svaka Decodable5 conformance koju kompajler sintetiše takođe @MainActor-isolated. actor ServerClient je sopstveni actor, ne MainActor, pa ne može da pozove te conformances ili da pristupi MainActor-isolated state-u kao što je KeychainStore.apiKey6. Svaki call site tražio je await i hop, a kompajler je odbijao polovinu kao cross-actor violations.

Popravka: napraviti ServerClient kao @MainActor final class. Na početku zvuči kontraintuitivno — „zar main thread nije samo za UI?“ — dok se ne setiš da URLSession.data() oslobađa main actor dok čeka mrežu. Concurrent network overlap ostaje automatski; main thread je zauzet samo tokom JSON decoding-a i delivery rezultata, a oba su brza.

Prikaži deklaraciju ServerClient (Swift, ~15 linija)
/// MainActor-isolated because the project's default actor isolation
/// is MainActor, which means every Decodable conformance synthesised
/// by the compiler is also MainActor-isolated. URLSession.data
/// releases the main actor while awaiting the network round-trip,
/// so concurrent requests still overlap on the wire.
@MainActor
final class ServerClient {
    static let shared = ServerClient()

    private let session: URLSession
    private let decoder: JSONDecoder
    private var cachedLang: String?

    init() {
        let config = URLSessionConfiguration.default
        config.httpAdditionalHeaders = ["Accept": "application/json"]
        self.session = URLSession(configuration: config,
                                  delegate: NoRedirectDelegate(),
                                  delegateQueue: nil)
        self.decoder = JSONDecoder()
    }
    // ... fetchDashboard, fetchBriefing, postHealth, etc.
}

Jedina neobična stvar ovde je @MainActor na vrhu. Iznenađenje je bilo to što je prirodan izbor bio pravi čim sam prestao da se borim sa kompajlerom.

Ovo je dokumentovano u CLAUDE.md za iOS app, jer bi takvu odluku spoljni contributor — ili budući ja — inače ponovo osporavao svakih šest meseci.

Dvoslojna lokalizacija i zašto slojevi smeju da se ne poklapaju

Aplikacija govori tri jezika: engleski, ruski, srpski. Isto važi za web dashboard. Isto važi za Telegram report. Arhitektonsko pitanje bilo je: ko poseduje svaku jezičku string vrednost?

Granica je na kraju ovakva:

  • UI chrome — tab labels, section titles, buttons, error banners, settings rows — lokalizuje se na klijentu kroz String Catalog7 (Localizable.xcstrings). Prati iOS locale. iOS daje per-app language toggle u Settings → Health Sync, uključen preko UIPrefersShowingLanguageSettings.

  • Server-side content — briefing text, alert wording, section names, metric labels, AI insight text — lokalizuje se na serveru. iOS app šalje ?lang=…, server vraća stringove spremne za render. Vrednost jezika dolazi iz server setting-a korisnika report_lang (/api/settings), a ne iz device locale.

Ta dva sloja smeju da se ne poklapaju. Ako na web-u postavim report_lang=sr, a telefon mi je na ruskom, iOS UI biće ruski, a briefing content srpski. To nije bug. To je namerno.

Razlog je što „briefing language“ i „device language“ odgovaraju na različita pitanja. Device language je „na kom jeziku mi je udobno da koristim aplikaciju“. Briefing language je „na kom jeziku mi je udobno da čitam svoje health data“. Taj jezik se jednom postavlja na serveru, prati me kroz web dashboard, iOS app i Telegram bot, i menja se samo kada eksplicitno odlučim da promenim jezik health-data.

Ako se ti slojevi pomešaju, dobijamo jedno od dva loša rešenja: briefing language prati locale telefona — pa ne mogu da čitam engleski health text na telefonu sa ruskim locale bez dance u podešavanjima; ili locale telefona prati briefing setting — što je pogrešno, jer OS chrome treba da prati opšte device preferences. Razdvajanje košta jedan pasus u SPEC-u i daje pravo da se to više nikada ne objašnjava.

Dublja korist: server catalogues internal/health/i18n_*.go postaju single source of truth za content strings. Kada se pojavi nova metrika sa novim display name, app update nije potreban. Catalog se osveži, API vrati nove stringove, sledeći pull-to-refresh ih prikaže.

Refactor arc: tri puta sam uhvatio klijenta kako računa

Pravilo „bez business logic na klijentu“ nije bilo dekret koji sam savršeno pratio od prvog dana. To je bila disciplina koju je trebalo ponovo uspostaviti tri puta — svaki put nakon što je deo business logic tiho izrastao na uređaju.

AIInsightParser (PR #12)

AI briefing text u jutarnjem izveštaju prvobitno je dolazio kao jedan Markdown blob. Gemini-generated prose sa neformalnim section markers: „Sleep: …“, „Yesterday: …“. iOS app je trebalo da prikaže svaki odeljak pod sopstvenim collapsible header na Today tabu.

Zato sam napisao AIInsightParser. Tokenized blob, split po section markers, normalised whitespace, oporavak posle malformed Markdown-a ako Gemini počne da bude kreativan. Radio je. I morao je da se ažurira svaki put kada se AI prompt makar malo promeni, jer section markers nisu bili stabilan contract. Bili su samo ono što Gemini slučajno izbaci u konkretnoj verziji prompt-a.

Prava popravka bila je na serveru, ne na klijentu. Prvi deo je to kratko opisao: AI briefing se generiše i čuva per block u ai_briefing_blocks. SLEEP, YESTERDAY, RECOVERY, RECOMMENDATION — odvojeni rows sa ključem (date, lang, block), cached by input-hash, tako da kasni HRV sample invalidira samo blokove koji od njega zavise. Kada je takav storage shape postojao, API je mogao direktno da vrati structured blocks:

{
  "blocks": {
    "sleep": "...",
    "yesterday": "...",
    "recovery": "...",
    "recommendation": "..."
  }
}

Parsing više nije potreban. PR #12 (refactor(today): consume server-localized labels, delete AIInsightParser) potpuno je obrisao parser. Klijent sada čita blocks.sleep i renderuje ga. Ako Gemini sutra promeni format output-a, server per-block storage proguta promenu; iOS app ne mora ništa da zna o tome.

Parser je bio oko 150 linija Swift-a koje su postojale samo zato što server contract još nije bio dovoljno strukturiran. Brisanje je izgledalo kao mali refactor. Zapravo je bio trenutak kada je klijent priznao: interpretacija AI text nije njegov posao.

Hardcoded section list (PR #13)

Trends tab je na početku prikazivao hardcoded list od tri subsections: Cardio, Activity, Recovery. To su bile tri kategorije koje su postojale na web dashboard-u onog dana kada sam kopirao layout. Uneo sam ih kao Swift array. Tap row → push CardioDetailView / ActivityDetailView / RecoveryDetailView.

Nekoliko nedelja kasnije web dashboard je dobio četvrtu sekciju — Readiness. iOS Trends tab nije. Klijent nije znao za nju, jer je section list bio konstanta u kodu, ne vrednost sa servera.

Popravka je već napola postojala: /api/health-briefing vraća niz sections, gde svaki entry sadrži key, display_name, summary, status, deltas. Web dashboard taj niz renderuje dinamički. iOS Trends tab morao je da radi isto.

PR #13 (feat(trends): server-driven section list, drop hardcoded Cardio/Activity/Recovery) obrisao je hardcoded array i tri grane view routing-a. Trends tab sada iterira kroz sections i otvara generic SectionDetailView, parametrizovan key sekcije. Kada se na serveru pojavi nova sekcija, iOS app je vidi pri sledećem refresh-u.

Ovakav bug je lako promašiti, jer ništa ne ruši. iOS app nastavlja da prikazuje tri reda; korisnik vidi tri reda; spolja je sve u redu. Drift se vidi tek ako neko uporedi web i mobile jedan pored drugog. Do tada aplikacije već ko zna koliko nedelja tiho žive u neusaglašenom stanju.

Render server labels verbatim (PR #12 ponovo, plus follow-ups)

Suptilnija verzija istog problema: iOS app je imao svoje prevode metric display names. „Heart Rate“, „Сердечный ритм“, „Frekvencija srca“. Stajali su u Localizable.xcstrings. Server je takođe imao prevode istih naziva u internal/ui/i18n_{en,ru,sr}.go. Dva izvora istine za iste stringove.

Neko vreme su se poklapali. Onda je server dobio novu locale-specific formulaciju za „Resting Heart Rate“ na srpskom, a iOS app nije. Ista forma drift-a kao kod section list. Nevidljivo sa bilo kog pojedinačnog ekrana, vidljivo samo pri poređenju across surfaces.

Popravka — PR #12 i follow-up 30da2b2 fix(today): render server labels verbatim: svaki metric label, svaki section title, svaka status word koju server može da proizvede, renderuje se as-received. iOS String Catalog je zadržao entries samo za stvari o kojima server ne zna: literal words na Settings buttons, network error banners, accessibility labels koje moraju da prate iOS conventions.

Granica se pomerila još jedan korak unutra. Klijentski Localizable.xcstrings se smanjio. Ništa novo nije dodato; deo postojećih zapisa je obrisan.

Granica još korak dalje: methodology status

Kasniji refactor — PR #118 na serveru — proširio je isti princip na mesto o kom u početku nisam razmišljao: trust level svakog izračunatog score-a.

Hero web dashboard sada prikazuje male badges pored svakog score-a — po četiri reči: heuristic, personalized, ili validated floor, candidate, ili experimental, not production. Badge classes: heuristic_personalized — trenutna Readiness v1, tuned expert formula na personal baselines; heuristic_prescriptive — trenutni EnergyBank v1 u half-cutover stanju; validated_floor_candidate — EWMA45 sloj koji je 4. deo proverio naspram naive baselines; experimental_formula — EnergyBank v2 u production, ali pre calibration; labeling_framework_ready / experimental_not_production — za sub-scores koji imaju writers, ali još nisu surfaced as numbers.

Poenta badges nije vizuelno poliranje. Poenta je da je methodology status svakog score-a sada deo onoga što server govori klijentu da renderuje. Čitalac dashboard-a iz prvog pogleda vidi da li je broj expert heuristic na ličnim podacima, validated floor ili experimental layer. Isto će važiti za iOS app nakon povezivanja: prikazaće badge pored score-a, tekst badge-a dolazi iz server i18n catalogue, a badge class iz server methodology table.

To je prirodan kraj refactor arc-a. Brisanje AIInsightParser prebacilo je content AI insight-a na server. Uklanjanje hardcoded section list prebacilo je list of dimensions na server. Verbatim-rendering labels prebacilo je display strings na server. Methodology badges prebacuju trust level svakog score-a na server. Na svakom koraku klijent je postajao manji, a server poštenije formulisao šta traži od klijenta da prikaže.

Dashboard hero sa oznakama statusa metodologije pored svakog rezultata Svaki score sada nosi svoj methodology badge: „heuristic, personalized“ / „experimental, formula“ / „validated floor, candidate“. Tekst badge-a dolazi iz server i18n catalogue; klijent ga renderuje verbatim.

Tu je i strukturna korist. Ista dashboard stranica sada može pored sebe da prikaže Readiness number sa tagom „heuristic, personalized“ i Recovery Stability number sa tagom „validated floor, candidate“. Bez badge-a ti brojevi bi izgledali nerazlučivo. Sa badge-om čitalac može da pita: „koji od njih ima metodološku podršku, a koji je korisna tuned guess?“ — i dobije pošten odgovor iz istog JSON payload-a koji je stvorio same brojeve.

Šta ostaje na klijentu, a čega namerno nema

Četiri stavke gore su ceo spisak client responsibilities, ali dve zaslužuju još jedan prolaz.

Local-only state. Keychain6 čuva API key. SwiftData8 čuva server URL, sync history — poslednjih 50 zapisa, per-metric HKQueryAnchor i retry queue za failed payloads. Sve to govori o odnosu ovog uređaja sa serverom, ne o health data korisnika. Ti podaci ne rade round-trip. Ako se ulogujem na drugi uređaj, oba će voditi svoj local state; jedina shared truth je ono što živi na serveru.

Pull-to-refresh i error handling. Kada mreža nije dostupna, app prikazuje last-cached briefing sa malim subtitle „last refreshed N minutes ago“. Ne izmišlja values. Ne pretvara stale number u svež. Ne extrapolate-uje iz starih podataka da popuni gap, i to je važno. U 5. delu mnogo je bilo o disciplini priznavanja odsustva sensor data; klijent istu disciplinu prenosi na situaciju „server trenutno nije dostupan“.

HealthKit cross-check namerno nedostaje. Nativni klijent mogao bi da pita HealthKit na uređaju, uporedi ono što HealthKit trenutno zna o današnjem HRV/RHR/sleep sa onim što je na serveru i pokaže „server is missing data“. To je jedna od onih funkcija koje izgledaju očigledno korisno i u SPEC-u su označene kao out of scope za v1.

Razlog nije capacity. Razlog je uloga. Danas je uređaj producer (2. deo) i viewer (ovaj članak). To su dve uloge, ali se jasno ne preklapaju: producer-side piše u /health, viewer-side čita iz /api/*. Dodavanje HealthKit cross-check-a učinilo bi uređaj i auditor-om servera: treća uloga sa drugim failure modes, drugim state to manage i drugim šansama da se raziđe sa authoritative view servera. Želim da svaka uloga postane čvrsta pre nego što se pojavi sledeća. Ne sve odjednom.

Prava lekcija

Iskušenje pri pravljenju drugog klijenta je da mu daš ista mišljenja koja ima prvi klijent. Razmišljanje je razumno: podaci su isti, korisnik isti, željeno ponašanje isto. Dakle, malo client-side logic je u redu, zar ne?

Svaki put kada sam popustio, kasnije je koštalo refactor-a: parse-and-delete za AI text, server-driven list za sections, removed strings za labels. Pojedinačno, nijedan od tih refactors nije bio težak. Ali pattern je bio previše jasan. Svaka business logic na klijentu je business logic kojom server ne može autoritativno da vlada. Svaki put kada sam je ship-ovao, potpisivao sam se na održavanje dve kopije u sinhronu.

Sada je iOS app mali na način koji je prvo izgledao kao propuštena prilika, a zatim se ispostavio kao poenta. Prenosi podatke u sistem. Renderuje podatke iz sistema. Ne interpretira ništa između te dve tačke.

Prethodni članak bio je o poverenju u verdict. Ovaj je o poverenju u granicu. Treba znati gde mišljenja sistema imaju pravo da žive, i ne dozvoliti im da se razmnožavaju po surfaces samo zato što postoji iskušenje.

Ostala je jedna uloga koju sistem treba da definiše pre nego što build log zaista može da se zaustavi. Do sada su svi inputs dolazili od sensors, a svi outputs od formulas. Obe strane su u uskom smislu objektivne: HealthKit javlja šta je izmerio, server tumači ono što je stiglo. Korisnik je do sada bio u petlji samo kao objekat merenja, ne kao glas u petlji. Sledeći članak je o dodavanju tog glasa: one-tap morning check-in, koji pita kako dan počinje, gate-uje morning report na osnovu odgovora i nema pravo da radi baš ništa od onoga što je 6. deo zabranio self-report-u.


  1. Kursor za inkrementalnu sinhronizaciju HealthKit-a. Neprozirni token koji vraća HKAnchoredObjectQuery, a koji omogućava sledećem upitu da preuzme samo uzorke dodate od poslednjeg uspešnog poziva. Čuva se po metrikama u SwiftData, tako da klijent nikada ne ponovo čita starije podatke; ako sinhronizacija ne uspe, sidro se ne pomera, i sledeće buđenje posmatrača pokušava ponovo od iste tačke. ↩︎

  2. Okvir Apple-a koji omogućava aplikacijama da izlože tipizirane radnje sistemu: Prečice, Siri, Spotlight, režimi fokusa. Namera poput SyncNowIntent postaje Prečica koju korisnik može pokrenuti bilo gde na iOS-u, uključujući putem glasa. ↩︎

  3. Apple-ov API za periodično buđenje aplikacije radi obavljanja zadataka bez korisničkog interfejsa. Sistem odlučuje kada će se to desiti na osnovu trenutnog nivoa baterije uređaja, dostupnosti mreže i obrazaca aktivnosti korisnika. Potreban je za svaku funkciju koja mora da obavi nešto dok korisnik ne koristi aktivno aplikaciju, poput proveravanja HealthKit-a za nove podatke o snu tokom noći. ↩︎

  4. Primarni concurrency primitiv u Swift 6. actor je referentni tip čije je interno stanje automatski serijalizovano (samo jedna nit ga može menjati odjednom), a pristup izvan njega zahteva await. Prirodno rešenje za „stvar koja drži promenljivo stanje i poziva se iz više korutina“. @MainActor je poseban actor vezan za glavni UI thread; tipovi izolovani na njega mogu biti dodirnuti samo iz koda glavnog thread-a (ili putem await sa drugih mesta). ↩︎

  5. Standardni protokol Swifta za „ovaj tip može biti inicijalizovan iz spoljašnje reprezentacije poput JSON-a“. Kompajler može automatski sintetisati usklađenost za tipove vrednosti čija su sva polja sama Decodable, što čini parsiranje JSON-a u modernom Swiftu gotovo bez potrebe za boilerplate-om. ↩︎

  6. Šifrovano skladište akreditiva na nivou sistema Apple-a. Stavke su povezane sa uređajem i identitetom potpisivanja aplikacije, preživljavaju reinstalacije aplikacije i dostupne su samo originalnoj aplikaciji (ili aplikacijama koje dele njenu keychain access grupu). Pravo mesto za tokene, API ključeve i tajne; pogrešno mesto za proizvoljne korisničke podatke. ↩︎ ↩︎

  7. Moderni format lokalizacije Apple-a (uveden 2023). Jedna JSON datoteka sadrži sve izvore stringova i prevode, sa prvoklasnom podrškom za pravila množine i varijacije. Zamenjuje staru kombinaciju .strings + .stringsdict↩︎

  8. Apple-ov okvir za upravljanje podacima uveden 2023. godine kao Swift-native naslednik Core Data. Šema-iz-izvora-istine (@Model klase), automatska migracija za jednostavne promene šeme i uska integracija sa SwiftUI-jem. ↩︎