Health Dashboard, 1. deo: ocena kojoj mogu da verujem
Prvi deo mog dnevnika izgradnje Health Dashboard-a: kako je jednostavan prijemnik za Apple Health postao koristan tek kada su ingestion, sirovi payload-i, obnova keša i provere kvaliteta podataka postali važniji od same ocene.
Ovo je prvi deo serije o izgradnji mog self-hosted Health Dashboard-a. Prva verzija nije bila metodološki projekat. Bila je to cev: uzmi podatke iz Apple Health-a, pošalji ih POST zahtevom na moj server i ujutru prikaži ocenu.
Zvuči čisto i jednostavno — sve dok prva loša noć podataka ne završi u Postgres-u. Ocena zdravlja je pouzdana samo onoliko koliko je pouzdana ingestion putanja ispod nje. Rana verzija ovog sistema uglavnom me je toj lekciji učila na teži način.
Jedan od prvih režima kvara izgledao je ovako:
insert: insert point heart_rate/2026-04-19 08:47:39 +0200: timeout: context deadline exceeded
POST /health/vitals 32.057s
Iz ugla telefona, sinhronizacija nije uspela. Iz ugla servera, deo payload-a je možda već bio upisan. Iz ugla dashboard-a, sledeći readiness broj mogao je da se zasniva na danu koji je učitan samo napola. To je baš ona vrsta baga zbog koje lep grafikon postaje ne samo beskoristan, nego aktivno štetan.
U toj prvoj fazi još sam commit-ovao direktno u projekat. Pull request-ovi su došli kasnije, kada je sistem dobio dovoljno površine da code review i eksplicitne kontrolne tačke opravdaju dodatnu ceremoniju.
Prva prava granica
Arhitektura koja je preživela namerno je dosadna:
POST /health -> InsertRaw -> health_records -> 200 to client
|
v
InsertPoints
|
v
metric_points -> hourly_metrics -> daily_scores
Ključna odluka: health_records je izvor istine. Sirovi JSON payload se čuva pre parsiranja, agregacije, računanja ocena, AI sažetaka i obaveštenja. Sve što je nizvodno izvedeno je i može ponovo da se izgradi.
Ta jedna granica promenila je sistem. Parser koji padne više nije incident gubitka podataka. Greška u formuli za ocenu postala je problem invalidacije keša. Sumnjiva vrednost na dashboard-u mogla je da se isprati nazad do konkretnog sirovog payload-a koji ju je proizveo.
Ista granica je učinila API ugovor jasnijim. Prva trajna odgovornost servera nije „izračunati readiness“. Jednostavnija je: „prihvatiti merenje i ne izgubiti ga“.
Trenutni glavni blok dashboard-a — ono što pipeline na kraju prikazuje. Ocena levo je readiness number koji ova serija od osam delova pokušava da učini poštenim.
Ocene su došle kasnije
Prva readiness ocena bila je namerno jednostavna:
readiness = HRV_score * 0.40 + RHR_score * 0.30 + Sleep_score * 0.30
Svaka komponenta poredila je sveže vrednosti sa ličnim baseline-om. To je bilo dovoljno da dashboard postane koristan, ali ne i dovoljno da bude naučno jak. Bila je to heuristika, ne validirani prognostički model.
Ta razlika je važna. Proizvodi za nosive uređaje često sakriju ovaj deo iza samouverenog broja. Ja sam hteo suprotno: svaki modul treba da nosi sopstvenu oznaku poverenja. Neki delovi su stručno podešene heuristike. Neki su eksperimentalni. Neki mogu da postanu kandidati tek ako pobede naivni baseline na izdvojenim podacima.
Sama ocena postala je manje zanimljiva od pitanja oko nje: kakav dokaz bi me naterao da poverujem ovom broju sutra ujutru?
Objašnjenja su došla pre AI-ja
Prva objašnjenja nije generisao model. To su bili rule-based bullets i kartice „Kako ovo radi“: HRV1, puls u mirovanju2, faze sna, SpO23, VO2 max4, frekvencija disanja, aktivnost i readiness — svaki blok je imao eksplicitan tekst vezan za pravila ocenjivanja i beleške iz literature.
To je bilo važno jer sistem mora da ostane proverljiv čak i kada je Gemini isključen. AI je došao kasnije kao drugi sloj: detaljniji tekst ispod rule-based sekcija, zatim generisanje po pojedinačnim blokovima — Sleep, Yesterday, Recovery i Recommendation — uz keširanje po hash-u ulaznih podataka, tako da kasno pristigli HRV sample invalidira samo blokove koji zavise od njega.
Pravilo proizvoda ostalo je isto: AI može da objašnjava i daje kontekst, ali ne zamenjuje determinističko objašnjenje. Ako Gemini kaže nešto čudno, numeričke stavke i metodološke beleške i dalje ostaju na mestu — i sa njima može da se raspravlja.
Keš nije istina
Sistem prvo čita keš, jer dashboard ne može pri svakom zahtevu da skenira sirove time-series podatke:
metric_pointsčuva parsirana merenja.hourly_metricsih agregira po satima i izvorima.daily_scoresih svodi na pokazatelje sna, HRV-a, pulsa u mirovanju, aktivnosti, kiseonika, disanja i readiness-a.
Ali kešu se ne sme dozvoliti da postane istina. Kada stignu novi health podaci, server ponovo izgradi pogođene datume direktno iz metric_points. Odloženi debounced backfill radi kao sigurnosna mreža. Potpuna obnova može da obriše izvedene tabele i ponovo ih izračuna iz sirovih merenja.
Zvuči kao implementacioni detalj, ali to je razlika između debugovanja health proizvoda i debugovanja spreadsheet-a. Ako ocena izgleda pogrešno, treba mi put unazad: do merenja, izvora, dnevnog bucket-a i verzije formule.
Invalidacija keša postala je deo proizvoda
Priča o keširanju imala je sopstvenu mini-evoluciju. U početku je rebuild delovao kao veliki čekić: očisti izvedene tabele i preračunaj ceo svet. To radi dok je baza mala. Ali prestaje da bude razumna operativna praksa kada produkciona tabela ima 4.695.057 redova u metric_points, a dashboard opslužuje više tenant-a.
Rešenje je bilo da popravka keša bude proporcionalna promeni. Ako migracija dotiče samo mali skup sleep redova, treba da obnovi samo te datume kroz isti put kojim prolazi svež /health ingest. Upravo to je targeted putanja UpsertRecentCache na kraju uradila za migraciju sleep_unspecified: obnovila je pogođene datume kroz ingestion path umesto punog pokretanja make backfill --force.
Pod jednim imenom krila su se tri različita problema invalidacije keša:
- Sveži podaci: posle
POST /health, odmah ponovo izgraditi pogođene satne i dnevne redove, a zatim pustiti debounced 48-časovni backfill da pokupi repove. - Promene formula: podići
ScoreVersion, kako bi se keširani readiness redovi tretirali kao zastareli posle promene pravila scoring-a. - Promene reprezentacije: kada metrika promeni značenje, na primer pri izdvajanju grubog sna u
sleep_unspecified, ažurirati izvorne redove i obnoviti samo datume koji su mogli biti pogođeni.
Kasniji bug u readiness API-ju naglasio je istu misao na drugom nivou. Server je vraćao stabilne readiness bands za briefing response, ali iOS sparkline je i dalje čitao /api/readiness-history i držao sopstvene pragove. Rešenje nije bilo „ažurirati konstante u aplikaciji“. Rešenje je bilo da server postane jedini izvor istine za semantiku opsega na oba endpoint-a.
To je postalo pravilo koje se ponavlja: ako izvedena vrednost može da nadživi kod koji ju je stvorio, potrebna joj je priča o invalidaciji. Ako dva klijenta mogu da prikazuju isti koncept, taj koncept mora imati jednog vlasnika.
Istorijski podaci došli su iz Apple Health export-a
Live ingestion bio je samo polovina priče. Health sistem koji počinje danas nije naročito koristan za baseline-ove, trendove i kalibraciju. Trebala mi je istorija.
Tu se pojavio importer za Apple Health export. iOS ume da izveze veliki export.zip, u kom se nalazi apple_health_export/export.xml. Jedan reprezentativan export koji sam proveravao imao je 149 MB kompresovano i razvijao se u export.xml od 2,4 GB. Na serveru postoji standalone importer cmd/import i web upload flow u Settings. Oba koriste isti parser internal/applehealth: streaming XML decoder koji može da obradi višegigabajtne export.xml fajlove bez učitavanja celog fajla u memoriju.
Gore pomenuta putanja Apple Health Auto Export koristi iOS aplikaciju treće strane5, koja radi po rasporedu i POST-uje HealthKit6 podatke na podesivi HTTP endpoint. Tako je server prvi put počeo da prima podatke, još pre nego što je postojao moj sopstveni iOS kod.
Importer mapira HealthKit tipove zapisa u ista interna imena metrika koja koristi live sync, normalizuje Apple-ove procente zasnovane na razlomcima u skalu 0–100 za dashboard, piše tačke u batch-evima i podržava --dry-run za parsiranje bez dodirivanja baze. CLI verzija na kraju ponovo izgradi agregirane keševe i ocene. Web verzija prati napredak, pokreće samo jedan import po tenant-u i invalidira importovani opseg datuma.
Suptilan deo bilo je rešavanje konflikata. Health Auto Export i Apple Health export mogu da pokrivaju iste datume, ali nisu jednako autoritativni. Za istorijsku popravku, puni Apple Health export postao je ground truth: pri importovanju preklapajućeg opsega sistem uklanja Auto Export redove za te datume i ponovo gradi izvedene keševe. Tako popunjavanje rupa ne postaje popunjavanje duplikatima.
Ta podela postala je još jedna važna granica: Apple Health export je način da bootstrap-ujem ili popravim istoriju; live sync je način da sistem ostane aktuelan.
Potrošački health podaci traže usaglašavanje
Sledeća lekcija: „podaci iz Apple Health-a“ nisu jedan dataset. To je dogovor između Apple Watch-a, iPhone-a, RingConn-a, importer-a, sažetaka, fragmenata i ponekad zastarelih uređaja.
San je bio najjasniji primer. Ukupan broj sati sna može da izgleda ispravno, dok je raspodela po fazama pogrešna. Grubi asleep zapisi mogu da se preklapaju sa preciznijim fazama sa Apple Watch-a. Ponoćni summary-ji mogu da dupliraju stvarne fragmente. Jedan izvor može da pošalje sleep_total sa fazama, drugi samo neodređeni blok sna.
Rešenje nije bila jedna pametna formula, već skup malih pravila:
- držati
sleep_unspecifiedodvojeno od sna sa fazama; - preferirati Apple Watch u odnosu na iPhone i druge izvore kada je to fiziološki opravdano;
- deduplikovati grafikone drugačije od dnevnog scoring-a;
- sačuvati prethodni red u
daily_scores, umesto da krivi izvor sa jednom metrikom obriše noć sa normalnom raspodelom po fazama.
Poslednje pravilo odražava ceo projekat: kada je kvalitet podataka dvosmislen, bolje je izabrati eksplicitnu nesigurnost nego samouvereno pokvariti rezultat.
Prvo kvalitet podataka, onda pametan model
Na kraju je ingestion sloj dobio tri zaštitna mehanizma:
- Odbacivati fiziološki nemoguće vrednosti pre upisa.
- Dodati flag
qualityumetric_points:ok,impossibleilisuspect. - Pokretati blagi z-score7 audit autonomnih metrika u odnosu na klizni lični baseline.
U trenutnoj produkcionoj tabeli, od 4.695.057 redova, 4.695.042 imaju status ok, a 15 su suspect. Primeri: respiratory_rate=34.5 za 2026-05-02 i heart_rate_variability=186.22012 za 2026-05-10. Dovoljno su uverljivi da bi bilo pogrešno slepo ih obrisati, i dovoljno neobični da bi bilo pogrešno bez provere njima hraniti baseline.
Čitanje baseline-ova sada filtrira podatke po quality='ok'. To je zahtevalo podizanje verzije readiness keša, jer je stari keš bio izračunat po drugačijim pravilima poverenja.
Ovo je neglamurozni deo izgradnje ličnog health modela. Možete nedelju dana da projektujete pametniju ocenu, ali ako senzorski skok ili duplirani segment sna završi u baseline-u, ocena će biti samo lepo formatirana laž.
Zašto sam prerastao Health Auto Export
Health Auto Export bio je prava početna tačka. Dao mi je JSON payload-e, zakazane export-e i dovoljno pokrivenosti da shvatim u šta server treba da se pretvori.
Ali kada je server počeo da uzima u obzir identitet izvora, vreme sinhronizacije, nepotpune dane, edge case-ove sna i cross-checking, generički exporter postao je pogrešan control plane. Taj pomak je tema drugog dela: prelazak sa „prihvati sve što Health Auto Export pošalje“ na sopstveni ingestion sloj sa nativnom iOS aplikacijom.
Prava lekcija
Dashboard je počeo od ocene, ali ocena nije bila proizvod. Proizvod je bilo poverenje.
Prva korisna verzija nije bila ona u kojoj formula readiness-a najbolje izgleda na papiru. Bila je ona u kojoj sam mogao brzo da odgovorim na dosadna pitanja:
- Da li je telefon poslao podatke?
- Da li je server sačuvao sirovi payload?
- Koji izvor je proizveo ovu metriku?
- Da li je ova vrednost filtrirana, označena ili korišćena u baseline-u?
- Mogu li ponovo da izgradim izvedene tabele bez gubitka originalnog merenja?
Varijabilnost srčanog ritma. Fluktuacije na nivou milisekundi u intervalima između uzastopnih otkucaja srca. Paradoksalno, veća varijabilnost je bolja: stabilan metronomski srčani ritam ukazuje na dominaciju simpatičkog nervnog sistema (stres, bolest, umor), dok srce koje slobodno menja tempo ukazuje na parasimpatički oporavak. HRV je kanonski marker autonomnog stanja u savremenim istraživanjima nosivih uređaja. ↩︎
Osnovni srčani ritam. Srčani ritam izmeren tokom odmora, idealno tokom poslednjih sati noćnog sna. Porast tokom dana je klasičan znak bolesti ili preopterećenja; pad tokom nedelja ukazuje na poboljšanje kardiovaskularne kondicije. ↩︎
Zasićenje krvi kiseonikom. Procenat hemoglobina koji nosi kiseonik, mereno optički kroz kožu. Zdrave vrednosti u mirovanju kreću se u opsegu od 95–100%; trajni padovi ispod 92% tokom sna mogu ukazivati na apneju ili respiratorne probleme. ↩︎
Maksimalna stopa potrošnje kiseonika tokom vrhunca vežbanja, u mL/(kg·min). Apple Watch procenjuje to na osnovu trajektorija srčanog ritma tokom hodanja i trčanja, a ne direktnim merenjem. Jak korelat sa kardiovaskularnom kondicijom i rizikom od smrtnosti od svih uzroka u longitudinalnim studijama. ↩︎
Health Auto Export. Aplikacija treće strane za iOS (nije moja izrada) koja periodično čita podatke iz HealthKit-a i šalje ih kao JSON na HTTP endpoint koji korisnik konfiguriše. Prva putanja za ingestion u ovom projektu; drugi deo objašnjava zašto i kako je dodat nativni iOS klijent. ↩︎
Apple-ov okvir za čuvanje i pretraživanje podataka o zdravlju i fitnesu na iOS-u. Svi podaci sa senzora sa Apple Watch-a, uređaja treće strane za praćenje sna poput RingConn-a, i ručno uneti podaci prolaze kroz HealthKit na uređaju pre nego što bilo kakav izvoz stigne do servera. ↩︎
Broj standardnih devijacija koje vrednost zauzima od osnovne sredine. Z-vrednost od 2 znači „dva puta udaljenije od proseka u poređenju sa uobičajenim dnevnim varijacijama.“ Omogućava sistemu da uporedi devijacije preko različitih metrika sa vrlo različitim jedinicama (devijacija HRV od +2σ i devijacija RHR od +2σ su podjednako iznenađujuće, iako su skale bpm i ms nepovezane). ↩︎