An iOS app that turns Apple Health activity into clear, good-looking statistics — for the last 24 hours, a day, a week, a month, a quarter, a year or any custom range.
The app reads steps and workouts from HealthKit, aggregates them in a pure Swift domain layer and renders them with Swift Charts: zoomable bar charts, a cumulative progress comparison and press-and-drag value inspection. Every workout opens into a Health-style detail screen with its route, splits and statistics.
Status: 1.3.0. Statistics, charts, data scopes, background updates, workout detail with routes, ten languages, social sharing and configurable widgets are done. Goals are not implemented yet.
- Periods — rolling 24 hours, calendar day (hourly buckets), week, month (default), quarter, year and custom ranges, with back/forward navigation, "Today" and locale-aware week boundaries.
- Two data scopes — All data (continuous Health monitoring: all-day steps, walking/cycling/ swimming distance, ascent from flights climbed) or Activities (only samples inside workouts), switchable on the dashboard and remembered between launches.
- Metrics — steps, distance, elevation gain, exercise duration and activity count, each with a total, an average and a comparison against the previous period or the same period last year. A period in progress is compared only with the same elapsed part of the previous one.
- Charts — bar chart per period with pinch/button zoom, horizontal pan and tap/drag tooltips; cumulative "progress over time" chart with the comparison period overlaid.
- Workout detail — route map, duration, distance, average pace, elevation, energy, steps, average and maximum heart rate, plus per-kilometre (or per-mile) splits.
- Automatic naming — each workout is titled from when and where it happened: "Your morning walk around Brtnice", "Your night run around Okříšky". Time of day comes from the start time, the settlement from reverse-geocoding the centre of the recorded route. Names appear in the workout list too: rows start as "Your afternoon run" and rename themselves once the settlement is known, which is cached so it is geocoded only once. With no route or no place, the sentence simply drops the location.
- Background updates — HealthKit background delivery plus an on-device cache of daily totals, so the app is current when it opens and a month or year view does not re-read years of samples.
- Widgets — a Home Screen widget in every size, configured with the same filters as the app: period (including a custom range), how many periods back, All data or Activities, which baseline to compare against, and which metrics to show. The size decides how many fit — one on small, three on medium, five on large — so nothing is crowded, and each size gets a bar chart with the average. Every size also shows the comparison value, the average per bucket and the best bucket, and the large one adds a per-metric grid with its own trend arrows.
- Sharing — one tap on the dashboard shares the period as filtered: the range, the All data / Activities scope and the metric on screen, drawn together with the chart, the comparison and all five totals. A workout shares its own card — route, name, date and numbers. Both render to a 1080 × 1350 PNG named after the period or the workout, which is the 4:5 shape Instagram, Facebook, Messenger and WhatsApp crop to without losing anything.
- Ten languages — English, Czech, German, Spanish, French, Italian, Portuguese, Japanese, Korean and Simplified Chinese. Either follow the device (unsupported languages fall back to English) or pick one in Settings; the choice is applied on the next launch. Everything is translated, including workout names, which are rebuilt from per-language sentence templates so word order and prepositions are right ("Your morning walk around Brtnice", "ranní procházka – Brtnice", "Brtnice周辺の朝のウォーキング").
- Settings — distance (km/mi) and elevation (m/ft) units, language, default period, comparison baseline, cache status and a clear-cache action.
- Demo data — a deterministic generator (including plausible-looking routes around Vysočina towns) makes the app fully usable in the simulator without any Health data; on by default in simulator builds. Demo mode is always labelled — a "Demo" chip on the dashboard, a footer in the workout list and "Sample route — demo data" under the map — so generated data is never mistaken for a recording.
| Tool | Version |
|---|---|
| Xcode | 26 or newer (iOS 26 SDK) |
| iOS deployment target | 26.0 |
| XcodeGen | 2.46+ (brew install xcodegen) |
brew install xcodegen
make generate # regenerate StatsWorkout.xcodeproj from project.yml
make open # or open StatsWorkout.xcodeproj manuallyThen pick an iPhone simulator and run. The app starts with demo data, so the dashboard is populated immediately.
To run on a device, select your development team in Signing & Capabilities; the HealthKit
capability, DEVELOPMENT_TEAM and NSHealthShareUsageDescription are already configured.
make build # build for the simulator
make test # run the unit tests on the simulator
make shots # capture the App Store screenshots (both devices)
make testflight # archive and upload a build to TestFlight
make deploy-site # publish the support/privacy site
make clean # remove build productsIf xcodebuild reports that it requires Xcode, point the toolchain at a full Xcode:
make select-xcode (or sudo xcode-select -s /Applications/Xcode.app).
The first time the dashboard loads real data the app asks for Health access itself: the system sheet lists the eight read types (steps, walking/running, cycling and swimming distance, flights climbed, active energy, heart rate and workouts). If you skip the sheet, turn off Use demo data in Settings → Data source and use Settings → Apple Health → Request read access.
HealthKit never reveals whether read access was granted, so an empty dashboard means "no data" or "access denied" — the Health app shows the current permission under Sharing → Apps → StatsWorkout.
Features (SwiftUI views + @Observable view models)
↓
Domain (pure Swift: periods, aggregation, geometry, naming, formatting)
↑
Data (HealthSyncCoordinator + cache → HealthDataProviding → HealthKit | Mock)
- Domain has no HealthKit, MapKit or SwiftUI imports, so period maths, aggregation, route
geometry and naming are unit-tested with a fixed
Calendar, time zone and injectednow. StatsAggregatorturns samples intoAggregatedStatsfor both data scopes in a single pass: bucket totals, period totals, previous-period totals for the elapsed window and a bucket-aligned previous series for the cumulative chart.RouteGeometrycomputes haversine distances, bounds (for framing the map), the centroid used for naming and per-kilometre splits with interpolated crossing times.WorkoutNamingturns a workout plus a place name into "Your morning walk around Brtnice".- Data is behind a
Sendableprotocol:HealthKitDataSourceuses the asyncHKSampleQueryDescriptor,HKStatisticsCollectionQueryDescriptor,HKStatisticsQueryDescriptorandHKWorkoutRouteQueryDescriptorAPIs;MockHealthDataSourceproduces stable pseudo-random data from a fixed seed. HealthSyncCoordinatorcaches daily totals in Application Support and only re-reads the days since the last sync (plus a two-day overlap that also catches edits and deletions), with a full re-read at most once a day. Day and 24-hour views read hourly data live, so the cache never holds hourly buckets.HealthKitBackgroundDeliveryenables background delivery for the observed types and keeps oneHKObserverQueryper type; HealthKit wakes the app when something changes.PlaceResolvingreverse-geocodes the route centre through CoreLocation, cached per ~1 km; demo data resolves to the towns the synthetic routes are drawn around.
| Metric | All data | Activities |
|---|---|---|
| Steps | all-day stepCount |
steps recorded inside workouts |
| Distance | walking/running, cycling and swimming distance | workout distance |
| Elevation gain | flights climbed × 3 m, or the workout's ascent if larger | workout's recorded ascent |
| Exercise duration | workout duration | workout duration |
| Activities | number of workouts | number of workouts |
HealthKit does not attach step statistics to workouts (HKWorkout.statistics(for: .stepCount)
returns nil), so the Activities scope reads steps and distance from statistics queries scoped to
the workout time ranges; the workout's own statistics are only a fallback when no samples come back.
Workout routes are a separate HealthKit sample type (HKSeriesType.workoutRoute()). Requesting
read access to workouts does not include them: without that type HealthKit returns no route samples
at all, and a workout that clearly has a route in the Health app appears route-less here. The read
set therefore includes the route type, and a workout with no route offers "Grant access to routes".
When a route is missing the detail screen also reports how many route samples HealthKit returned for the workout's time window — 0 means "no access or no route", more than 0 means the trace exists but is not linked to that workout, which separates the two causes without guessing.
A workout's route is exactly what Apple Health recorded: the HKWorkoutRoute samples belonging
to that workout, fetched through HKQuery.predicateForObjects(from:) so two workouts that overlap in
time can never mix their traces. Nothing is synthesised, smoothed or extended; workouts recorded
without a route simply show no map. Only demo data draws its own traces, and those are labelled in
the UI.
A workout is always resolved against the source that produced it: anything carrying a HealthKit
identity is read from HealthKit, so switching demo data on can never repaint a recorded workout with
a generated route. The source is resolved by the caller and passed into the detail view model —
never looked up inside it — because a view model built in init cannot read @Environment and an
earlier placeholder environment silently handed every detail screen a mock.
The detail screen states where its trace came from, under the map: Recorded by Apple Health, Sample route — demo data, or No route recorded by Apple Health for this workout. A "Route details" disclosure adds the source, point count and trace time, so the truth is readable on the device rather than inferred from a screenshot.
Splits are timed from the route — each boundary is interpolated from the timestamps of the two surrounding points — but laid out along the distance HealthKit recorded for the workout. A GPS trace is usually a few percent longer than that distance (corners, drift), so using the raw trace length would push every split late; the calibration keeps the split count and the totals consistent with the rest of the screen.
| Input | Result |
|---|---|
| 07:00 walk, geocoded to Brtnice | Your morning walk around Brtnice |
| 23:30 run, geocoded to Okříšky | Your night run around Okříšky |
| 18:00 strength training | Your evening strength workout |
| 15:00 ride, no place resolved | Your afternoon ride |
Time of day: night 22–05, morning 05–12, afternoon 12–17, evening 17–22.
The sentence itself lives in the language catalogs (naming.title with %1$@ time of day,
%2$@ activity noun, %3$@ place), so each language can order it its own way instead of having
an English pattern glued together.
The widget extension (Sources/Widgets, plus Sources/Shared/WidgetUI for the views) keeps itself
current rather than decorating a snapshot:
- It reads Apple Health itself. A widget refresh goes through the same
HealthSyncCoordinatorthe app uses, so the numbers are current while the user is walking or working out with the app closed — and the read refreshes the shared cache on its way through. - The cache is the fallback. If Health access is missing or a query fails, the widget aggregates
the app's daily roll-ups from the App Group container instead. Same
StatsAggregator, so the two can never disagree. - The app pushes refreshes. Every sync — foreground or a HealthKit background wake-up — rewrites
the cache and calls
WidgetCenter.reloadAllTimelines()(WidgetBridge), as does a change of units, language or data source. That is what makes a widget move while the phone is in a pocket: iOS alone would only refresh it on its own budgeted schedule.
The rest:
| File | Role |
|---|---|
Sources/Shared/WidgetCore.swift |
WidgetFilters (the configuration, in app types), WidgetDataSource, the placeholder sample data |
Sources/Shared/WidgetUI/ |
the widget's body and its size rules — shared so they render in tests |
Sources/Widgets/StatsWidgetIntent.swift |
AppIntentConfiguration: period, periods back, custom range, data scope, comparison, metrics |
Sources/Widgets/StatsTimelineProvider.swift |
loads the cache, refreshes every 30 minutes |
Sources/Widgets/StatsWidget.swift |
the WidgetBundle |
Three things are deliberate:
WidgetFiltersrather thanWidgetConfiguration— the latter is WidgetKit's protocol, and a type with that name shadows it insidevar body: some WidgetConfiguration.- Configuration labels come from
.strings, not the app's catalogs. iOS draws the settings screen itself, before any of our code runs, and resolves those strings against the device language.tools/generate-widget-strings.pywritesResources/WidgetStrings/<code>.lprojfrom the same translations, so the two never drift. The widget's content still usesL10n, i.e. the language picked in the app. - The widget mirrors the app's language and units into the shared defaults
(
SharedPreferences), because an extension cannot read the app'sUserDefaults.
StatsWidgetView takes an optional familyOverride: widgetFamily is read-only in the
environment, and the render tests need to draw each size.
Sources/Features/Share/ turns what a screen already computed into an image — no HealthKit call and
no new permission, so a card always matches the screen it came from:
| File | Role |
|---|---|
DashboardShareCard.swift |
period title, scope, hero metric, bars and the other four totals |
WorkoutShareCard.swift |
workout name, date, the shape of the route and six numbers |
ShareCard.swift |
card chrome: gradient, wordmark, stat rows, bar chart, route trace |
ShareComposer.swift |
builds the card, renders it with ImageRenderer, writes the PNG |
ShareSheet.swift |
UIActivityViewController, plus the failure alert |
Two details worth keeping:
- The card is drawn with plain shapes rather than Swift Charts, because it is rendered off screen and the result has to be identical every time.
- The route is a stylised trace of the recording, not a map snapshot: it is the real shape, it
carries no street names to a social network, and it needs no network to render.
RouteGeometry .unitPolylinefits it to the square with the latitude correction, so a loop does not come out stretched.
The sheet is handed a file (<workout or period>.png in the app's temporary directory) rather
than an in-memory UIImage: the thumbnail appears, the name is readable, and Instagram and WhatsApp
accept it. If writing the file fails, the in-memory image is shared instead.
Strings live in Resources/L10n/<code>.json and are read by a small typed layer:
L10nKey— every key as aL10nKeycase, so a typo fails to compile instead of showing a raw key at runtime.AppLanguage— the ten languages, their plural rules and the device-resolution rule (system→ best match forLocale.preferredLanguages→ English).L10n— one thread-safe loader that caches a table per language and falls back to English, then to the key.
The active language is read at launch in StatsWorkoutApp.init, before the first string is built,
and AppEnvironment.locale hands the same locale to the formatters, so dates, numbers and lists
match the chosen language rather than the device. Tests can render a language without touching the
running app via L10n.$languageOverride.withValue(...).
Tests/LocalizationTests.swift fails if a catalog is missing a key, has an empty value, or drops a
%1$@-style placeholder that English uses.
Sources/
App/ app entry point, dependency container, settings, screenshot routes, localisation
Domain/ models, period calendar, aggregation, route geometry, naming, formatters
Data/ HealthKit + mock sources, workout mapping, sync coordinator, cache, places
Features/ Dashboard, ActivityLog (list + detail + map), Share, Settings
Shared/ App Group, shared preferences, widget filters + data + views
Widgets/ the widget extension: intent, timeline provider, widget bundle
DesignSystem/ theme, cards, pills, tiles
Tests/ Swift Testing suites
Supporting/ generated Info.plist and entitlements
Website/ the published support and privacy pages
screenshots/ App Store artwork captured from the simulator
tools/ PNG flatten/strip helpers and the icon generator
project.yml XcodeGen specification (the source of truth for the project)
StatsWorkout.xcodeproj is generated and committed so the project opens without extra tooling.
Do not edit it by hand — change project.yml and run make generate. Run make generate after
adding or deleting source files.
make test104 tests cover period boundaries (including DST and leap years), bucket generation, aggregation for
both data scopes, partial-period comparison, unit formatting, the sample cache and refresh planning,
route geometry and splits, workout naming, and the language catalogs (completeness, placeholders,
plural forms and word order), and the share cards (pixel size, non-blank render, axis ticks, and the
route fit), and the widgets (filter resolution against the dashboard's own windows, cache loading
for both data scopes, metric capacity per size, and a render of all three sizes). Chart cards, the map and the detail screen ship with
#Previews backed by the mock data source, so visual work does not need a device.
CI runs the same build and test on every pull request (see .github/workflows/ci.yml).
Workflow, branch naming and PR requirements are described in CONTRIBUTING.md.
main is protected: changes land through pull requests with a green CI run.
- Widgets and Live Activity summaries
- Goal setting and rings
- Right-to-left languages (the catalogs are ready for them)
- Exporting a period as CSV
make shots captures the App Store screenshots from clean simulators (6.1"/6.3" iPhone and
13" iPad) into screenshots/en/; make shots-cs fills screenshots/cs/ with the
same frames in Czech. See screenshots/README.md for the frames and the
debug-only launch arguments behind them (-screenshotWorkout for the detail screen,
-screenshotHideDemoLabels for clean artwork). The capture waits for the app to report a screen as
loaded rather than sleeping, and retakes the frame until two attempts match, so a skeleton card or a
mid-scroll frame cannot end up in the store.
Icon candidates are in docs/icons/; the shipped icon is the pulse-trend one. Store copy
lives in docs/app-store-listing.md — python3 tools/check-listing-limits.py
verifies it against Apple's character limits.
make testflight # build number defaults to a timestamp
make testflight BUILD_NUMBER=42 # or pass an explicit build numberThe script archives in Release, signs with automatic provisioning for team B592D69P29 and uploads
to App Store Connect. Prerequisites:
- Xcode is signed in with the team's Apple ID (Xcode → Settings → Accounts).
- An app record for
com.defik74.statsWorkoutexists in App Store Connect.
Bump MARKETING_VERSION in project.yml before shipping a new version.
The privacy policy and support pages required for review live in Website/ and are published at:
- https://stats-workout.ondralinek.cz/ — landing page (English, Czech under
/cs/) - https://stats-workout.ondralinek.cz/privacy.html
- https://stats-workout.ondralinek.cz/support.html
make deploy-site rsyncs Website/ to deploy@2.31.33.21:/var/www/stats-workout. The app links to
the same pages from Settings → About. Listing copy for every App Store Connect field is in
docs/app-store-listing.md; the submission process is in
docs/app-store.md.
