Skip to content

Feature: Endocrinologist Visit Report (PDF Export)#670

Closed
greyghost99 wants to merge 14 commits into
loopandlearn:mainfrom
type1ghost:feature/endo-report
Closed

Feature: Endocrinologist Visit Report (PDF Export)#670
greyghost99 wants to merge 14 commits into
loopandlearn:mainfrom
type1ghost:feature/endo-report

Conversation

@greyghost99
Copy link
Copy Markdown

✨ Feature: Endocrinologist Visit Report (PDF Export)

Summary

This PR adds a PDF report generator to LoopFollow that produces a professional, clinic-ready endocrinologist visit report directly from Nightscout data — no third-party services, no external dependencies, and no internet connection required beyond the existing Nightscout connection.

The report is inspired by the LoopInsights report format and designed to give endocrinologists a clean summary of a patient's AID performance over any chosen period.


Motivation

Patients using DIY AID systems (Loop, Trio, iAPS) often arrive at endo appointments without structured data summaries. Apps like Dexcom Clarity and Tidepool produce reports but don't have visibility into Loop/Trio-specific data. LoopFollow already fetches and displays all the relevant data — this feature simply packages it into a shareable PDF that patients can AirDrop, email, or print before their visit.


What's New

Report Contents (all sections are individually toggleable)

  • Glucose Summary — eA1C/GMI estimate, Time in Range (standard + tight 70–140), average glucose, standard deviation, CV%, and total readings
  • Time in Range Distribution — colour-coded vertical bar with ADA target annotations (1% ≈ 15 min)
  • Glucose by Time of Day — six-period strip (Night / Early AM / Morning / Afternoon / Evening / Late) showing average BG per period
  • Ambulatory Glucose Profile (AGP) — full median + IQR + 5–95th percentile bands across the report period
  • Insulin Delivery — avg TDD, basal/bolus split %, correction boluses, SMB/auto-correction count, programmed vs actual basal, positive/negative temp basal adjustments
  • Nutrition & Meals — avg daily carbs, meal count, per-meal average
  • Current Therapy Settings — CR, ISF, basal rate, target glucose (manually entered or auto-filled from Nightscout profile API)
  • Devices & System — AID system, pump, CGM, insulin type
  • Daily Breakdown (Pages 2+) — one row per day, newest to oldest, with glucose scatter plot (colour-coded dots), bolus bars, SMB bars, basal fill/line, carb markers, and per-day stats (avg BG, TIR%, bolus total, scheduled basal, Bolus:Basal ratio, data coverage)

Report Options

  • Date range presets: 3 / 7 / 14 / 30 / 90 days, plus custom start/end date pickers
  • mg/dL or mmol/L toggle
  • Custom accent colour picker
  • Toggle each section on/off individually
  • Daily breakdown pages on/off toggle
  • Fat & protein entry toggle

Auto-Fill from Nightscout

A "Auto-Fill from Nightscout" button in the Therapy Settings section calls /api/v1/profile/current.json using the existing NightscoutUtils infrastructure and pre-fills CR, ISF, basal rate schedule, and target glucose — no manual re-entry needed.


Files Added

LoopFollow/Stats/EndoReportGenerator.swift
Core PDF rendering engine using PDFKit + CoreGraphics. Contains EndoReportConfig, EndoReportGenerator, per-page layout, all chart drawing, and the daily breakdown row renderer. No external dependencies.

LoopFollow/Stats/EndoReportView.swift
SwiftUI sheet UI for configuring and generating the report. Contains EndoReportView, NightscoutProfileFetcher, ShareSheet, and Color/UIColor hex extensions. All settings are persisted via @AppStorage.

Files Modified

LoopFollow/Stats/AggregatedStatsView.swift
Added @State private var showEndoReport = false, a toolbar button (doc.richtext icon) next to the existing Refresh button, and a .sheet presentation of EndoReportView.


Technical Notes

  • No new dependencies — uses only PDFKit, UIKit, CoreGraphics, and SwiftUI, all already available in the project.
  • Reuses existing calculatorsAGPCalculator, TIRCalculator, GRICalculator, SimpleStatsViewModel, and StatsDataService are used directly. No data is re-fetched.
  • Nightscout profile pull — uses the existing NightscoutUtils.executeRequest with .profile event type. Includes a lenient three-layer decoder that handles missing fields gracefully across Nightscout variants (Trio, Loop, standard).
  • Multi-page pagination — daily breakdown pages calculate dynamically. Each page fits as many day rows as space allows and overflows automatically.
  • Thread safety — PDF generation runs on a background DispatchQueue and dispatches UI updates back to main.
  • Settings persistence — all report configuration (patient name, provider, devices, therapy settings, colour, toggles) is persisted via @AppStorage.

Testing

Tested on iPhone simulator (iOS 26.5) with real Nightscout data. Verified:

  • PDF generates and opens correctly in iOS Quick Look
  • Share sheet correctly presents AirDrop, Mail, Save to Files
  • Auto-fill correctly parses single-entry and multi-entry CR/ISF/basal schedules from Nightscout profile
  • mmol/L conversion renders correctly throughout

Date range presets — all tested:

  • 3 days ✓
  • 7 days ✓
  • 14 days ✓
  • 30 days ✓
  • 90 days ✓
  • Custom date range (manual start/end date picker) ✓

Daily breakdown pagination tested across all ranges — confirmed correct number of pages generated and all days present, newest to oldest, for each preset and custom range.


Screenshots

Report Configuration UI

Report Period & Patient Info

Report Period & Patient Info

Devices & Therapy Settings

Devices & Therapy Settings

Therapy Settings Auto-Filled from Nightscout

Therapy Settings

Schedule Mode with Chart Preview

Schedule Mode

Section Toggles & Theme Colour

Section Toggles

Generated PDF

Page 1 — Summary Dashboard

PDF Page 1

Page 2 — Daily Glucose Breakdown

PDF Page 2


Checklist

  • New files added to correct target (LoopFollow)
  • No new external dependencies introduced
  • Existing calculators and data services reused, not duplicated
  • All settings persisted via @AppStorage
  • UI follows existing LoopFollow patterns (SwiftUI, Storage.shared, dateTimeUtils)
  • PDF generation is off-main-thread
  • Nightscout profile auto-fill uses existing NightscoutUtils infrastructure
  • Unit tests (PDF layout is inherently visual — manual testing performed)

bjorkert and others added 14 commits May 30, 2026 22:25
loopandlearn#649)

* Move release flow to PR-based and add CODEOWNERS for branch protection

- Add .github/CODEOWNERS
- Cut a release branch in release.sh and open two PRs (sync to dev,
  release to main) instead of merging dev into main locally
- Move tag creation into a workflow that fires on push to main
- Skip auto_version_dev when Config.xcconfig was changed in this push,
  so merging the release sync-PR into dev does not double-bump

* Make workflow guards portable via fork check

Replace the hardcoded 'loopandlearn' owner check in workflows with a
fork check, so the workflows run on any non-fork repository (including
a test org) while still skipping on contributor forks.

* Allow skipping sister repo updates in release.sh

Set SKIP_SISTER_REPOS=1 to bypass the LoopFollow_Second / LoopFollow_Third
update_follower steps. Default behavior is unchanged: both sister repos
are updated as today and missing directories still cause a hard error,
so a forgotten clone in production fails fast.

* Revert "Allow skipping sister repo updates in release.sh"

This reverts commit c2792b8.

* Skip patch hunks for files missing from sister repos

Sister repos (LoopFollow_Second / LoopFollow_Third) are intentionally
stripped of dev-only files like release.sh, auto_version_dev.yml,
lint.yml, and warn_main_pr.yml. Any release patch that touches one of
those files used to abort the sister-repo update with 'No such file or
directory'. Now update_follower runs git apply --check first, parses
the missing-file errors, and re-applies with --exclude for each, so the
sister patch covers the files that actually exist.

* Revert "Skip patch hunks for files missing from sister repos"

This reverts commit 45b9871.
* Diagnose and skip rogue Nightscout profile records

Profile fetch now uses /api/v1/profiles?count=1 with find[startDate][$lte]=now,
so future-dated records can no longer block the active profile. Adds a
"Run diagnostics" button in the Remote Settings Debug section that fetches
14 days of profile history and surfaces three failure modes:

- Bundle ID mismatch when Loop and Trio share a Nightscout
- Alternating device tokens from multiple installations
- Future-dated profile records left over from a wrong-clock uploader

The bouncing-tokens check compresses consecutive same-token runs and only
warns on actual token alternation, not normal token rotation.

* Widen bouncing-token check and surface shift history

Three changes to the profile diagnostics:

- Drop the 14-day find[startDate][$gte] filter. A slow A→B→A pattern
  spread across months only registers as one transition inside a 14-day
  window, so the bouncing-tokens check would silently miss it on servers
  that honor the filter. The existing 1000-record cap now defines the
  scope, which goes back as far as upload frequency allows.
- Fall back to created_at when sorting profile records, so uploaders
  that omit startDate don't cluster at .distantPast and corrupt the
  run-length compression.
- Include the chronological list of token shifts in the bouncing-tokens
  warning. Each row shows when the shift happened and the abbreviated
  from→to tokens, so users can see at a glance which devices are
  competing instead of just "3 tokens involved across N records".
…wed LA (loopandlearn#656)

pendingForegroundRestart could outlive the condition that triggered it:
a brief foreground entry while the renewal overlay was up latched the
intent, the user backgrounded before didBecomeActive ran, the background
renewal then replaced the LA, and the next foreground entry minutes later
fired the deferred restart against an already-fresh LA.

- adoptPushToStartActivity clears pendingForegroundRestart on every
  adoption — a freshly-adopted LA resolves the renewal-window condition
  that latched the intent.
- performForegroundRestart re-checks renewalFailed / overlayShowing /
  pushToStartLooksStuck before tearing down; bails if none still hold.
- Deferred-foreground-restart push-to-start is tagged
  reason="deferred-foreground-restart" via a single-shot
  nextStartReasonOverride, so the stale-latch event is no longer
  indistinguishable from a real user start in logs.
A 6.1.0 user reported the Live Activity vanishing and refusing to come
back without a manual Restart. Trace: APNs returned 410 on the per-
activity push token at 04:42; handleExpiredToken ended the activity but
the eventual iOS .dismissed (4 h later, under the default dismissal
policy) was classified as a user swipe and locked dismissedByUser=true.

Root cause is two cooperating bugs around an app-initiated end():

- end() nulls `current` and clears laRenewBy. handleExpiredToken's
  comment said "Activity will restart on next BG refresh via
  refreshFromCurrentState()", but renewIfNeeded short-circuits when
  current is nil and performRefresh's bind-existing path rebinds to the
  just-ended activity. bind() then clears endingForRestart, so the late
  .dismissed reads as renewBy=0 / renewalFailed=false / endingForRestart=
  false — branch (c) "USER" in the classifier.

- The classifier had no way to recognize a stale observer firing for an
  activity the app no longer tracks.

Fixes:

- handleExpiredToken drives the restart synchronously on iOS 17.2+
  (attemptPushToStartCreate "expired-token"), so the orphaned post-410
  state is short-lived and adoption of the fresh activity cancels the
  old observer.

- performRefresh / update bind-existing only to activities in
  .active state. Binding to an .ended/.dismissed corpse would clear
  endingForRestart and re-attach an observer that only ever delivers
  .dismissed.

- .dismissed classifier gains branch (d): if the dismissed activity is
  not the one we currently track, log and take no action — only the
  foreground LA can be user-swiped, so a stale-observer delivery for an
  already-replaced activity must not latch dismissedByUser=true.
When iOS reaches the Live Activity lifetime cap before renewal fires it
delivers .ended, not .dismissed. The state observer only ran restart
logic on .dismissed, so handleForeground saw renewalFailed=false and
renewBy still in the future and returned "no action needed", leaving
the LA dark until manual force-restart.

Mark laRenewalFailed=true on the .ended path (gated on wasCurrent and
!endingForRestart) so the next foreground entry triggers
performForegroundRestart, which sweeps the corpse activity and pushes a
fresh one.
…oopandlearn#661)

Dexcom Share returns each reading twice when both the iPhone Dexcom
app and the Apple Watch app upload to the same account (~9-10 s apart,
same SGV). Without deduplication the two most recent entries in bgData
were always identical, producing delta = 0.

The NS fetch path already had inline deduplication. Extract it into a
shared helper (deduplicateBGReadings) and apply it to the Dexcom-only
path as well.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

⚠️ This PR targets the main branch. We do not accept PRs directly to main — please retarget your PR to the dev branch instead.

@greyghost99 greyghost99 closed this Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants