Skip to content

About

iOS 26 app that turns Apple Health steps and workouts into weekly, monthly, quarterly and yearly stats with interactive Swift Charts.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

StatsWorkout

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.

StatsWorkout dashboard showing November totals, metric tiles and a steps chart

Features

  • 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.

Requirements

Tool Version
Xcode 26 or newer (iOS 26 SDK)
iOS deployment target 26.0
XcodeGen 2.46+ (brew install xcodegen)

Quick start

brew install xcodegen
make generate     # regenerate StatsWorkout.xcodeproj from project.yml
make open         # or open StatsWorkout.xcodeproj manually

Then 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.

Common commands

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 products

If xcodebuild reports that it requires Xcode, point the toolchain at a full Xcode: make select-xcode (or sudo xcode-select -s /Applications/Xcode.app).

Switching from demo data to Apple Health

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.

Architecture

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 injected now.
  • StatsAggregator turns samples into AggregatedStats for 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.
  • RouteGeometry computes haversine distances, bounds (for framing the map), the centroid used for naming and per-kilometre splits with interpolated crossing times.
  • WorkoutNaming turns a workout plus a place name into "Your morning walk around Brtnice".
  • Data is behind a Sendable protocol: HealthKitDataSource uses the async HKSampleQueryDescriptor, HKStatisticsCollectionQueryDescriptor, HKStatisticsQueryDescriptor and HKWorkoutRouteQueryDescriptor APIs; MockHealthDataSource produces stable pseudo-random data from a fixed seed.
  • HealthSyncCoordinator caches 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.
  • HealthKitBackgroundDelivery enables background delivery for the observed types and keeps one HKObserverQuery per type; HealthKit wakes the app when something changes.
  • PlaceResolving reverse-geocodes the route centre through CoreLocation, cached per ~1 km; demo data resolves to the towns the synthetic routes are drawn around.

Metric definitions

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.

Route access

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.

Routes

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

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.

Naming rules

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.

Widgets

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 HealthSyncCoordinator the 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:

  • WidgetFilters rather than WidgetConfiguration — the latter is WidgetKit's protocol, and a type with that name shadows it inside var 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.py writes Resources/WidgetStrings/<code>.lproj from the same translations, so the two never drift. The widget's content still uses L10n, 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's UserDefaults.

StatsWidgetView takes an optional familyOverride: widgetFamily is read-only in the environment, and the render tests need to draw each size.

Sharing

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 .unitPolyline fits 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.

Localisation

Strings live in Resources/L10n/<code>.json and are read by a small typed layer:

  • L10nKey — every key as a L10nKey case, 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 for Locale.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.

Project layout

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.

Testing

make test

104 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).

Contributing

Workflow, branch naming and PR requirements are described in CONTRIBUTING.md. main is protected: changes land through pull requests with a green CI run.

Roadmap

  • Widgets and Live Activity summaries
  • Goal setting and rings
  • Right-to-left languages (the catalogs are ready for them)
  • Exporting a period as CSV

Screenshots and artwork

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.

Publishing

make testflight                    # build number defaults to a timestamp
make testflight BUILD_NUMBER=42    # or pass an explicit build number

The 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.statsWorkout exists in App Store Connect.

Bump MARKETING_VERSION in project.yml before shipping a new version.

App Store pages

The privacy policy and support pages required for review live in Website/ and are published at:

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.

About

iOS 26 app that turns Apple Health steps and workouts into weekly, monthly, quarterly and yearly stats with interactive Swift Charts.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages