Skip to main content

Health Dashboard, часть 7: приложение, которое перестало считать

Часть 7 моего build log по Health Dashboard: как iOS-клиент вырос из чистого передатчика данных в read-only viewer, почему каждый рефактор удалял клиентскую логику вместо того, чтобы добавлять её, и как научиться говорить «нет» ещё одному маленькому правилу на устройстве.

· 16 мин чтения

Первые шесть частей этой серии были о том, как сервер учится иметь собственные мнения. Ingestion gates, идентичность источника, eligibility для sub-scores, границы версий формул, validation rubrics — каждый слой заслужил право выдавать число. Когда эта работа была сделана, на другой стороне провода появился новый вопрос: что iOS-клиенту вообще разрешено делать с этими числами?

Ответ оказался уже, чем я ожидал в начале. Самая интересная часть создания мобильного приложения была не в том, что в него добавлялось. А в том, что из него снова и снова удалялось, пока сервер учился делать каждую вещь лучше.

Эта часть — build log такого вычитания.

Чем приложение было в начале

Самые первые коммиты health-sync не создавали ничего, похожего на дашборд. Начальный UI состоял из трёх экранов: вкладка Settings для server URL и API key, вкладка Status с текстом «Last sync: 2 min ago / Failed (3 retries)» и TabView для переключения между ними.

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

И всё. Приложение было передатчиком. Нажать «Sync Now» или дождаться HKObserverQuery wakeup, посмотреть, как обновится строка статуса. Чтобы реально увидеть стадии сна или readiness history, я открывал health.dzarlax.dev в Safari и смотрел web dashboard.

Это была сознательно маленькая поверхность. В части 2 подробно описана ingestion-сторона. В тот момент открытые вопросы звучали так: «выживают ли per-segment sleep records в новом pipeline?» и «не приводят ли сбросы HKQueryAnchor1 после каждой неудачи к дублированию данных?» — а не «как должна выглядеть вкладка Activity?». Строить UI для данных, которым я ещё учился доверять, было бы преждевременно.

Это также означало, что перед добавлением новых экранов нужно принять настоящее решение: будет ли iOS-приложение вообще viewer-ом, или Safari навсегда достаточно?

Решение: нативный viewer, а не «открыть Safari»

Честный аргумент за «просто использовать web dashboard» такой: он работает, сервер уже говорит JSON-ом, Safari достаточно компетентен. В большинстве случаев этот аргумент побеждает.

Здесь он не победил по четырём причинам, в порядке реальной важности:

  1. Offline-friendly чтение. Web dashboard требует round-trip при каждой загрузке страницы. Нативное приложение может закэшировать последний ответ /api/health-briefing и мгновенно показать его, когда я нажимаю на иконку в 06:50 с одной полоской связи. То же самое работает в самолётах и там, где SIM в роуминге, а HTTPS до домашнего сервера доступен рывками.

  2. Тактильное равенство с остальным HealthKit. На iOS health data живёт в приложениях с нативными графиками, swipe gestures и системным sharing. Web-страница в Safari не ощущается частью этой экосистемы. Это в основном эстетика, но эстетика важна на устройстве, в которое заглядываешь каждое утро.

  3. Widgets и lock-screen surfaces. «Last sync · 142 pts» на экране блокировки возможно только через настоящий app target. То же относится к App Intents2, например SyncNowIntent для Shortcuts / Siri.

  4. Background sync. Путь BGProcessingTask3 всё равно требует native app target по причинам ingestion, не связанным с отображением. Когда такой target уже есть, не показывать в нём вообще ничего выглядит как потеря ресурса.

Коммит, в котором приложение перестало быть чистым передатчиком и стало viewer-ом, — одна строка в git log:

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

В этом PR появились пять вкладок: Today, Sleep, Trends, Metrics, Settings. Имена выбраны в соответствии с сервером. Это те же разделы, которые показывает web dashboard, с теми же JSON endpoints за ними.

Правило, которое упростило всё остальное

Когда делаешь второго клиента поверх API, которым сам владеешь, гравитация всегда тянет к дублированию server logic. Данные уже на устройстве, форма графика видна, понятно, каким цветом должен быть статус «good readiness». Всего несколько строк Swift — добавить helper readinessColor(for: Int) рядом с view.

Я не написал этот helper. Вместо него я добавил поле в ответ сервера:

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

Правило, которое сделало все последующие решения проще: никакой бизнес-логики на клиенте. Никакого readiness scoring. Никакой агрегации. Никакого source priority. Никакой sleep dedup. Никакого «если значение > 70, показать зелёным». Никаких таблиц переводов для display names метрик. Никакой интерпретации, которую сервер уже способен выполнить.

Клиент делает четыре вещи:

  • Рендерит charts и lists из server JSON.

  • Управляет навигацией между экранами.

  • Управляет local-only вещами: server URL, API key в Keychain, sync history — последние 50 записей, retry queue для failed payloads.

  • Общается с HealthKit на ingestion-стороне — территория части 2, отдельная роль, архитектурно отделённая от viewer-а.

Всё остальное — каждый слой интерпретации, каждое formatting rule, которое теоретически может отличаться по tenant или formula version, — живёт на сервере. Когда появляются новые метрики или новый copy, app update не нужен.

Это правило звучит сурово на бумаге. На практике оно убрало из проекта целые категории багов. Web dashboard и iOS app не могут разойтись в том, что означает «good readiness», потому что ни один из них не является источником этого определения.

ServerClient — это @MainActor final class, а не actor

Первая нетривиальная Swift 6-стена, в которую я врезался, — тип ServerClient. Рефлекторно я написал его как actor4. Concurrent network requests, async/await везде — казалось, очевидный выбор. Потом ничего не скомпилировалось.

Проект собирается со Swift 6 strict concurrency (-strict-concurrency=complete). Default actor isolation для проекта — MainActor. Это значит, что каждая Decodable5 conformance, которую синтезирует компилятор, тоже @MainActor-isolated. actor ServerClient — это собственный actor, не MainActor, поэтому он не может вызывать эти conformances или получать доступ к MainActor-isolated state вроде KeychainStore.apiKey6. Каждая call site требовала await и hop, а компилятор отвергал половину как cross-actor violations.

Исправление: сделать ServerClient классом @MainActor final class. Сначала это звучит контринтуитивно — «разве main thread не только для UI?» — пока не вспоминаешь, что URLSession.data() освобождает main actor на время ожидания сети. Concurrent network overlap сохраняется автоматически; main thread занят только на JSON decoding и delivery результата, а оба процесса быстрые.

Показать объявление ServerClient (Swift, ~15 строк)
/// 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.
}

Единственная необычная вещь здесь — @MainActor сверху. Сюрприз был в том, что естественный выбор оказался правильным, когда я перестал бороться с компилятором.

Это задокументировано в CLAUDE.md для iOS app, потому что такое решение внешний contributor — или будущий я — иначе будет заново оспаривать каждые полгода.

Двухслойная локализация и почему слои могут не совпадать

Приложение говорит на трёх языках: английском, русском, сербском. То же самое делает web dashboard. То же самое — Telegram report. Архитектурный вопрос был таким: кто владеет каждой языковой строкой?

В итоге граница такая:

  • UI chrome — tab labels, section titles, buttons, error banners, settings rows — локализуется на клиенте через String Catalog7 (Localizable.xcstrings). Следует iOS locale. iOS даёт per-app language toggle в Settings → Health Sync, включённый через UIPrefersShowingLanguageSettings.

  • Server-side content — briefing text, alert wording, section names, metric labels, AI insight text — локализуется на сервере. iOS app передаёт ?lang=…, сервер возвращает готовые к рендеру строки. Значение языка берётся из server setting пользователя report_lang (/api/settings), а не из device locale.

Эти два слоя могут не совпадать. Если я ставлю на web report_lang=sr, а телефон у меня на русском, iOS UI будет русским, а briefing content — сербским. Это не баг. Это намеренно.

Причина в том, что «briefing language» и «device language» отвечают на разные вопросы. Device language — «на каком языке мне удобно пользоваться приложением». Briefing language — «на каком языке мне удобно читать собственные health data». Этот язык задаётся один раз на сервере, следует за мной в web dashboard, iOS app и Telegram bot, и меняется только когда я явно решаю поменять язык health-data.

Если смешать эти слои, получится одно из двух плохих решений: briefing language следует за locale телефона — и тогда я не могу читать английский health text на телефоне с русской локалью без dance в настройках; или locale телефона следует за briefing setting — что неверно, потому что OS chrome должен соответствовать общим device preferences. Разделение стоит одного абзаца в SPEC и даёт право больше никогда это не объяснять.

Более глубокая выгода: server catalogues internal/health/i18n_*.go становятся single source of truth для content strings. Когда появляется новая метрика с новым display name, app update не нужен. Catalog обновляется, API возвращает новые строки, следующий pull-to-refresh их показывает.

Refactor arc: три раза я ловил клиент на вычислениях

Правило «никакой бизнес-логики на клиенте» не было декретом, которому я идеально следовал с первого дня. Это была дисциплина, которую пришлось восстанавливать трижды — каждый раз после того, как кусок бизнес-логики тихо вырастал на устройстве.

AIInsightParser (PR #12)

AI briefing text в утреннем отчёте изначально приходил одним Markdown blob. Gemini-generated prose с неформальными section markers: «Sleep: …», «Yesterday: …». iOS app должен был показать каждый раздел под собственным collapsible header на вкладке Today.

Поэтому я написал AIInsightParser. Он tokenized blob, split по section markers, normalised whitespace, восстанавливался после malformed Markdown, если Gemini начинал креативить. Он работал. И его приходилось обновлять каждый раз, когда AI prompt хоть немного менялся, потому что section markers не были стабильным contract. Это было просто то, что Gemini случайно выдавал в конкретной версии prompt.

Правильное исправление было на сервере, не на клиенте. Часть 1 уже коротко описывала это: AI briefing генерируется и хранится per block в ai_briefing_blocks. SLEEP, YESTERDAY, RECOVERY, RECOMMENDATION — отдельные rows с ключом (date, lang, block), cached by input-hash, чтобы поздний HRV sample инвалидировал только блоки, которые от него зависели. Когда такая storage shape появилась, API смог возвращать structured blocks напрямую:

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

Parsing больше не нужен. PR #12 (refactor(today): consume server-localized labels, delete AIInsightParser) полностью удалил parser. Клиент теперь читает blocks.sleep и рендерит его. Если Gemini завтра переформатирует output, server per-block storage поглотит изменение; iOS app не должен об этом знать.

Parser был примерно 150 строк Swift, которые существовали только потому, что server contract ещё был недостаточно структурирован. Удаление выглядело маленьким рефактором. На самом деле это был момент, когда клиент признал: интерпретация AI text — не его работа.

Hardcoded section list (PR #13)

Вкладка Trends сначала показывала hardcoded list из трёх subsections: Cardio, Activity, Recovery. Это были три категории, которые были на web dashboard в день, когда я копировал layout. Я набрал их Swift array. Tap row → push CardioDetailView / ActivityDetailView / RecoveryDetailView.

Через несколько недель web dashboard получил четвёртую секцию — Readiness. iOS Trends tab — нет. Клиент не знал о ней, потому что section list был константой в коде, а не значением с сервера.

Исправление уже наполовину существовало: /api/health-briefing возвращает массив sections, где каждый entry содержит key, display_name, summary, status, deltas. Web dashboard рендерит этот массив динамически. iOS Trends tab должен был делать то же самое.

PR #13 (feat(trends): server-driven section list, drop hardcoded Cardio/Activity/Recovery) удалил hardcoded array и три ветки view routing. Trends tab теперь итерируется по sections и открывает generic SectionDetailView, параметризованный key секции. Когда на сервере появляется новая секция, iOS app видит её при следующем refresh.

Такой баг легко пропустить, потому что он ничего не роняет. iOS app продолжает показывать три строки; пользователь видит три строки; внешне всё нормально. Drift проявляется только если кто-то сравнит web и mobile рядом. К тому моменту приложения уже неизвестное число недель тихо жили в несогласованном состоянии.

Render server labels verbatim (PR #12 снова, plus follow-ups)

Более тонкая версия той же проблемы: у iOS app были свои переводы metric display names. «Heart Rate», «Сердечный ритм», «Frekvencija srca». Они лежали в Localizable.xcstrings. У сервера тоже были переводы тех же названий в internal/ui/i18n_{en,ru,sr}.go. Два источника истины для одних и тех же строк.

Некоторое время они совпадали. Потом сервер получил новую locale-specific формулировку для «Resting Heart Rate» на сербском, а iOS app — нет. Та же форма drift, что и у section list. Невидимо с любого одного экрана, видно только при сравнении across surfaces.

Исправление — PR #12 и follow-up 30da2b2 fix(today): render server labels verbatim: каждый metric label, каждый section title, каждое status word, которое может произвести сервер, рендерится as-received. iOS String Catalog оставил entries только для вещей, о которых сервер не знает: literal words на Settings buttons, network error banners, accessibility labels, которые должны следовать iOS conventions.

Граница сдвинулась ещё на шаг внутрь. Клиентский Localizable.xcstrings уменьшился. В него ничего нового не добавили; часть существующих записей удалили.

Граница ещё на шаг дальше: methodology status

Более поздний рефактор — PR #118 на сервере — расширил тот же принцип на место, о котором я сначала не думал: trust level каждого вычисленного score.

Hero web dashboard теперь показывает маленькие badges рядом с каждым score — по четыре слова: heuristic, personalized, или validated floor, candidate, или experimental, not production. Badge classes: heuristic_personalized — текущая Readiness v1, tuned expert formula на personal baselines; heuristic_prescriptive — текущий EnergyBank v1 в half-cutover состоянии; validated_floor_candidate — слой EWMA45, который часть 4 проверила против naive baselines; experimental_formula — EnergyBank v2 в production, но до calibration; labeling_framework_ready / experimental_not_production — для sub-scores, у которых есть writers, но которые ещё не surfaced as numbers.

Смысл badges не в визуальной полировке. Смысл в том, что methodology status каждого score теперь часть того, что сервер говорит клиенту рендерить. Читатель dashboard с первого взгляда видит, является ли число expert heuristic на личных данных, validated floor или experimental layer. То же будет в iOS app после подключения: он покажет badge рядом со score, текст badge придёт из server i18n catalogue, а badge class — из server methodology table.

Это естественный финал refactor arc. Удаление AIInsightParser перенесло content AI insight на сервер. Отказ от hardcoded section list перенёс list of dimensions на сервер. Verbatim-rendering labels перенёс display strings на сервер. Methodology badges переносят trust level каждого score на сервер. На каждом шаге клиент становился меньше, а сервер честнее формулировал то, что просит клиента показать.

Герой дашборда с значками статуса методологии рядом с каждым баллом Теперь каждый score несёт свой methodology badge: «heuristic, personalized» / «experimental, formula» / «validated floor, candidate». Текст badge приходит из server i18n catalogue; клиент рендерит его verbatim.

Есть и структурная выгода. Одна и та же страница dashboard теперь может рядом показывать Readiness number с тегом «heuristic, personalized» и Recovery Stability number с тегом «validated floor, candidate». Без badge эти числа выглядели бы неотличимо. С badge читатель может спросить: «какое из них методологически подкреплено, а какое — полезная tuned guess?» — и получить честный ответ из того же JSON payload, который породил сами числа.

Что остаётся на клиенте, а чего намеренно нет

Четыре пункта выше — полный список client responsibilities, но два из них заслуживают второго прохода.

Local-only state. Keychain6 хранит API key. SwiftData8 хранит server URL, sync history — последние 50 записей, per-metric HKQueryAnchor и retry queue для failed payloads. Всё это про отношения этого устройства с сервером, а не про health data пользователя. Эти данные не делают round-trip. Если я залогинюсь на втором устройстве, оба будут вести своё local state; единственная shared truth — то, что живёт на сервере.

Pull-to-refresh и error handling. Когда сеть недоступна, app показывает last-cached briefing с маленьким subtitle «last refreshed N minutes ago». Он не придумывает values. Не делает вид, что stale number свежий. Не extrapolate-ит из старых данных, чтобы заполнить gap, и это важно. В части 5 много говорилось о дисциплине признавать отсутствие sensor data; клиент переносит ту же дисциплину на ситуацию «сервер сейчас недоступен».

HealthKit cross-check намеренно отсутствует. Нативный клиент мог бы спросить HealthKit на устройстве, сравнить то, что HealthKit сейчас знает про сегодняшние HRV/RHR/sleep, с тем, что есть на сервере, и показать «server is missing data». Это одна из тех функций, которые выглядят очевидно полезными и в SPEC помечены как out of scope для v1.

Причина не в capacity. Причина в роли. Сегодня устройство — producer (часть 2) и viewer (эта статья). Это две роли, но они чётко не пересекаются: producer-side пишет в /health, viewer-side читает из /api/*. Добавление HealthKit cross-check сделало бы устройство ещё и auditor сервера: третья роль с другими failure modes, другим state to manage и другими шансами разойтись с authoritative view сервера. Я хочу, чтобы каждая роль стала твёрдой до появления следующей. Не всё сразу.

Настоящий урок

Искушение при создании второго клиента — дать ему те же мнения, что есть у первого клиента. Рассуждение разумное: данные те же, пользователь тот же, желаемое поведение то же. Значит, немного client-side logic — нормально, да?

Каждый раз, когда я поддавался, позже это стоило рефактора: parse-and-delete для AI text, server-driven list для sections, removed strings для labels. По отдельности ни один из этих refactors не был сложным. Но паттерн был слишком явным. Каждая business logic на клиенте — это business logic, которой сервер не может владеть авторитетно. Каждый раз, когда я её ship-ил, я подписывался поддерживать две копии в синхроне.

Теперь iOS app маленький так, что сначала это выглядело упущенной возможностью, а потом оказалось смыслом. Он передаёт данные в систему. Он рендерит данные из системы. Он не интерпретирует ничего между этими двумя точками.

Предыдущая статья была о доверии к verdict. Эта — о доверии к границе. Нужно знать, где мнения системы имеют право жить, и не позволять им размножаться по surfaces просто потому, что соблазн есть.

Осталась одна роль, которую системе нужно определить, прежде чем build log сможет действительно остановиться. Пока все inputs приходили от sensors, а все outputs — от formulas. Обе стороны в узком смысле объективны: HealthKit сообщает, что измерил, сервер интерпретирует то, что пришло. Пользователь пока находится в петле только как объект измерения, а не как голос в петле. Следующая статья — о добавлении этого голоса: one-tap morning check-in, который спрашивает, как начинается день, gate-ит morning report по ответу и не имеет права делать ровно ничего из того, что часть 6 запретила self-report.


  1. Курсор инкрементальной синхронизации HealthKit. Непрозрачный токен, возвращаемый HKAnchoredObjectQuery, который позволяет следующему запросу получать только образцы, добавленные с момента предыдущего успешного вызова. Хранится для каждой метрики в SwiftData, чтобы клиент никогда не считывал старые данные; если синхронизация не удалась, якорь не продвигается, и следующий запуск наблюдателя повторяет попытку с той же точки. ↩︎

  2. Фреймворк Apple, позволяющий приложениям выставлять системе типизированные действия: Shortcuts, Siri, Spotlight, режимы фокусировки. Intent вроде SyncNowIntent становится Shortcut, который пользователь может запустить откуда угодно в iOS, в том числе голосом. ↩︎

  3. API Apple для периодического пробуждения приложения для выполнения задач без интерфейса пользователя. Система решает, когда запускать эти задачи, исходя из текущего уровня заряда батареи устройства, доступности сети и паттернов активности пользователя. Необходим для любой функции, которая должна выполнять действия, пока пользователь не активно использует приложение, например, опрос HealthKit о новых данных о сне ночью. ↩︎

  4. Основной примитив параллелизма в Swift 6. actor — ссылочный тип, внутреннее состояние которого автоматически сериализуется (только один поток может его менять в каждый момент), а доступ извне требует await. Естественно ложится на «штуку, которая держит изменяемое состояние и вызывается из нескольких корутин». @MainActor — специальный актор, привязанный к UI-потоку; изолированные к нему типы можно трогать только из кода главного потока (или через await из другого места). ↩︎

  5. Стандартный протокол Swift для «этот тип может быть инициализирован из внешнего представления, такого как JSON». Компилятор может автоматически сгенерировать соответствие для типов значений, все поля которых являются типами Decodable, что делает парсинг JSON в современном Swift практически без шаблонного кода. ↩︎

  6. Зашифрованное системное хранилище учётных данных Apple. Элементы связаны с устройством и идентификатором подписи приложения, сохраняются при переустановке приложения и доступны только для исходного приложения (или приложений, которые делят группу доступа к связке ключей). Место для хранения токенов, API-ключей и секретов; не подходит для произвольных пользовательских данных. ↩︎ ↩︎

  7. Современный формат локализации Apple (введённый в 2023 году). Один файл JSON содержит все исходные строки и переводы, с полноценной поддержкой правил множественного числа и вариантов. Заменяет устаревшую пару .strings + .stringsdict↩︎

  8. Фреймворк для управления данными Apple, представленный в 2023 году как нативный для Swift преемник Core Data. Схема из источника правды (классы @Model), автоматическая миграция при простых изменениях схемы и плотная интеграция с SwiftUI. ↩︎