Skip to content
Open
70 changes: 57 additions & 13 deletions LoopFollow/Controllers/MainViewController+updateStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,34 @@
import Foundation

extension MainViewController {
/// Start of the period both home screen stats cover: the last 24 hours, or since
/// midnight when chosen in Settings. Midnight follows the display time zone, so a
/// Time Zone Override is respected.
func homeStatsPeriodStart() -> TimeInterval {
if Storage.shared.statsSinceMidnight.value {
return dateTimeUtils.displayCalendar().startOfDay(for: Date()).timeIntervalSince1970
}
return dateTimeUtils.getTimeIntervalNHoursAgo(N: 24)
}

func updateStats() {
if bgData.count > 0 {
var lastDayOfData = bgData
let graphHours = 24 * Storage.shared.downloadDays.value
// If we loaded more than 1 day of data, only use the last day for the stats
if graphHours > 24 {
let oneDayAgo = dateTimeUtils.getTimeIntervalNHoursAgo(N: 24)
var startIndex = 0
while startIndex < bgData.count, bgData[startIndex].date < oneDayAgo {
startIndex += 1
}
lastDayOfData = Array(bgData.dropFirst(startIndex))
}
let periodStart = homeStatsPeriodStart()
let periodData = bgData.filter { $0.date >= periodStart }

let stats = StatsData(bgData: lastDayOfData)
if periodData.isEmpty {
// Nothing in the period yet (e.g. just after midnight): show placeholders
// rather than the previous period's numbers.
statsDisplayModel.lowPercent = "--"
statsDisplayModel.inRangePercent = "--"
statsDisplayModel.highPercent = "--"
statsDisplayModel.avgBG = "--"
statsDisplayModel.estA1C = "--"
statsDisplayModel.stdDev = "--"
statsDisplayModel.pieLow = 0
statsDisplayModel.pieRange = 0
statsDisplayModel.pieHigh = 0
} else {
let stats = StatsData(bgData: periodData)

statsDisplayModel.lowPercent = String(format: "%.1f%%", stats.percentLow)
statsDisplayModel.inRangePercent = String(format: "%.1f%%", stats.percentRange)
Expand Down Expand Up @@ -48,5 +61,36 @@ extension MainViewController {
statsDisplayModel.pieRange = Double(stats.percentRange)
statsDisplayModel.pieHigh = Double(stats.percentHigh)
}
updateTIRBand(periodStart: periodStart)
}

/// Range distribution for the Time in Range band over the same period as the
/// statistics box (last 24 hours, or today since midnight).
/// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR
/// or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or
/// the range's own bounds when a custom range extends past them.
func updateTIRBand(periodStart: TimeInterval) {
let thresholds = UnitSettingsStore.shared.effectiveBandThresholds()
let values = bgData
.filter { $0.date >= periodStart && $0.sgv > 0 }
.map { Double($0.sgv) }

statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeTitle

let percentages = TIRCalculator.calculatePercentages(
readings: values,
veryLowThreshold: thresholds.veryLow,
lowThreshold: thresholds.low,
highThreshold: thresholds.high,
veryHighThreshold: thresholds.veryHigh
)

statsDisplayModel.bandVeryLowPct = percentages.veryLow
statsDisplayModel.bandLowPct = percentages.low
statsDisplayModel.bandInRangePct = percentages.inRange
statsDisplayModel.bandHighPct = percentages.high
statsDisplayModel.bandVeryHighPct = percentages.veryHigh
statsDisplayModel.bandHasData = !values.isEmpty
statsDisplayModel.bandPeriod = Storage.shared.statsSinceMidnight.value ? "today" : "last 24h"
}
}
10 changes: 10 additions & 0 deletions LoopFollow/Controllers/StatsDisplayModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,14 @@ class StatsDisplayModel: ObservableObject {
@Published var pieLow: Double = 0
@Published var pieRange: Double = 0
@Published var pieHigh: Double = 0

// Time in Range band (same period as the statistics box)
@Published var bandTitle: String = "Time in Range"
@Published var bandPeriod: String = "last 24h"
@Published var bandHasData: Bool = false
@Published var bandVeryLowPct: Double = 0
@Published var bandLowPct: Double = 0
@Published var bandInRangePct: Double = 0
@Published var bandHighPct: Double = 0
@Published var bandVeryHighPct: Double = 0
}
99 changes: 99 additions & 0 deletions LoopFollow/Controllers/TIRBandView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// LoopFollow
// TIRBandView.swift

import SwiftUI

/// Time in Range band for the home screen: today's in-range
/// percentage above a segmented bar (very low / low / in range / high / very high).
/// The range follows the Range Mode in Settings (TIR, TITR or Custom).
/// Tapping opens the statistics screen.
struct TIRBandView: View {
@ObservedObject var model: StatsDisplayModel
var onTap: (() -> Void)?

private var percentText: String {
guard model.bandHasData else { return "-- %" }
return model.bandInRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %"
}

private var segments: [(color: Color, fraction: CGFloat)] {
guard model.bandHasData else { return [(Color.secondary.opacity(0.3), 1)] }
return [
(TIRGraphView.Band.veryLow.color, CGFloat(model.bandVeryLowPct / 100)),
(TIRGraphView.Band.low.color, CGFloat(model.bandLowPct / 100)),
(TIRGraphView.Band.inRange.color, CGFloat(model.bandInRangePct / 100)),
(TIRGraphView.Band.high.color, CGFloat(model.bandHighPct / 100)),
(TIRGraphView.Band.veryHigh.color, CGFloat(model.bandVeryHighPct / 100)),
]
}

var body: some View {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(percentText)
.font(.title2)
.fontWeight(.bold)
.fontDesign(.rounded)
.foregroundStyle(.primary)
(Text(model.bandTitle).fontWeight(.semibold) + Text(" " + model.bandPeriod))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}

DistributionBar(segments: segments)
.frame(height: 6)
}
.frame(maxWidth: .infinity, alignment: .leading)

Image(systemName: "chevron.right")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.frame(height: 64)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
.contentShape(Rectangle())
.onTapGesture { onTap?() }
}
}

/// Horizontal bar of capsules, one per non-empty segment, sized by share.
/// Every non-empty segment is at least `minSegmentWidth` wide so a single
/// reading stays visible.
private struct DistributionBar: View {
let segments: [(color: Color, fraction: CGFloat)]

private let spacing: CGFloat = 2
private let minSegmentWidth: CGFloat = 4

var body: some View {
GeometryReader { geo in
let shown = segments.filter { $0.fraction > 0 }
let available = max(geo.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0)
let widths = segmentWidths(shown.map(\.fraction), available: available)
HStack(spacing: spacing) {
ForEach(Array(shown.enumerated()), id: \.offset) { index, segment in
Capsule()
.fill(segment.color)
.frame(width: widths[index])
}
}
.frame(maxHeight: .infinity)
}
}

private func segmentWidths(_ fractions: [CGFloat], available: CGFloat) -> [CGFloat] {
var widths = fractions.map { max($0 * available, minSegmentWidth) }
let excess = widths.reduce(0, +) - available
if excess > 0, let widest = widths.indices.max(by: { widths[$0] < widths[$1] }) {
widths[widest] = max(widths[widest] - excess, minSegmentWidth)
}
return widths
}
}
19 changes: 19 additions & 0 deletions LoopFollow/Helpers/Localizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,25 @@ final class UnitSettingsStore {
}
}

/// Returns the very low, low, high and very high thresholds (in mg/dL) for
/// the current range mode. Very low is 54 and very high 250, or the range's
/// own bounds when a custom range extends past them.
func effectiveBandThresholds() -> (veryLow: Double, low: Double, high: Double, veryHigh: Double) {
let range = effectiveThresholds()
return (min(54.0, range.low), range.low, range.high, max(250.0, range.high))
}

var timeInRangeTitle: String {
switch timeInRangeMode {
case .tir:
return "Time in Range"
case .titr:
return "Time in Tight Range"
case .custom:
return "Custom Range"
}
}

var glycemicMetricMode: GlycemicMetricMode {
get {
Storage.shared.showGMI.value ? .gmi : .ehba1c
Expand Down
21 changes: 20 additions & 1 deletion LoopFollow/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ struct GeneralSettingsView: View {
@ObservedObject var appBadge = Storage.shared.appBadge
@ObservedObject var appearanceMode = Storage.shared.appearanceMode
@ObservedObject var showStats = Storage.shared.showStats
@ObservedObject var showTIRBand = Storage.shared.showTIRBand
@ObservedObject var statsSinceMidnight = Storage.shared.statsSinceMidnight
@ObservedObject var showSmallGraph = Storage.shared.showSmallGraph
@ObservedObject var screenlockSwitchState = Storage.shared.screenlockSwitchState
@ObservedObject var showDisplayName = Storage.shared.showDisplayName
Expand Down Expand Up @@ -44,7 +46,6 @@ struct GeneralSettingsView: View {
Text(mode.displayName).tag(mode)
}
}
Toggle("Display Stats", isOn: $showStats.value)
Toggle("Display Small Graph", isOn: $showSmallGraph.value)
Toggle("Color BG Text", isOn: $colorBGText.value)
Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value)
Expand All @@ -61,6 +62,24 @@ struct GeneralSettingsView: View {
}
}

Section {
Toggle("Display Stats", isOn: $showStats.value)
if showStats.value {
Picker("Stats Style", selection: $showTIRBand.value) {
Text("Statistics Box").tag(false)
Text("Time in Range Band").tag(true)
}
Picker("Stats Period", selection: $statsSinceMidnight.value) {
Text("Last 24 Hours").tag(false)
Text("Today (since midnight)").tag(true)
}
}
} header: {
Text("Statistics")
} footer: {
Text("The statistics box and the Time in Range band both cover this period. Today starts at midnight in your time zone, or in the Time Zone Override below when it is on.")
}

Section("Time Zone") {
Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value)
.onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() }
Expand Down
4 changes: 3 additions & 1 deletion LoopFollow/Settings/SettingsMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,15 @@ enum SettingsRoute: Hashable, Identifiable {
SettingsLeaf("Display App Badge", ["badge"]),
SettingsLeaf("Persistent Notification"),
SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]),
SettingsLeaf("Display Stats"),
SettingsLeaf("Display Small Graph"),
SettingsLeaf("Color BG Text"),
SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]),
SettingsLeaf("Show Display Name"),
SettingsLeaf("Snoozer emoji"),
SettingsLeaf("Force portrait mode", ["orientation", "landscape"]),
SettingsLeaf("Display Stats", ["statistics"]),
SettingsLeaf("Stats Style", ["time in range band", "statistics box", "tir band", "progress bar"]),
SettingsLeaf("Stats Period", ["statistics period", "last 24 hours", "since midnight", "today", "midnight"]),
SettingsLeaf("Time Zone Override", ["timezone"]),
SettingsLeaf("Speak BG", ["voice", "speech"]),
SettingsLeaf("Send anonymous usage stats", ["telemetry", "diagnostics"]),
Expand Down
16 changes: 8 additions & 8 deletions LoopFollow/Stats/TIR/TIRCalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ class TIRCalculator {
static func calculate(bgData: [ShareGlucoseData]) -> [TIRDataPoint] {
guard !bgData.isEmpty else { return [] }

let thresholds = UnitSettingsStore.shared.effectiveThresholds()
let veryLowThreshold = 54.0
let thresholds = UnitSettingsStore.shared.effectiveBandThresholds()
let veryLowThreshold = thresholds.veryLow
let lowThreshold = thresholds.low
let highThreshold = thresholds.high
let veryHighThreshold = 250.0
let veryHighThreshold = thresholds.veryHigh
var periodData: [TIRPeriod: [Double]] = [:]
let calendar = dateTimeUtils.displayCalendar()

Expand Down Expand Up @@ -91,11 +91,11 @@ class TIRCalculator {
return tirPoints
}

private static func calculatePercentages(readings: [Double],
veryLowThreshold: Double,
lowThreshold: Double,
highThreshold: Double,
veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double)
static func calculatePercentages(readings: [Double],
veryLowThreshold: Double,
lowThreshold: Double,
highThreshold: Double,
veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double)
{
let total = Double(readings.count)
guard total > 0 else {
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Stats/TIR/TIRGraphView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SwiftUI
struct TIRGraphView: View {
let tirData: [TIRDataPoint]

private enum Band: String, CaseIterable, Plottable {
enum Band: String, CaseIterable, Plottable {
case veryLow = "Very Low"
case low = "Low"
case inRange = "In Range"
Expand Down
Loading