diff --git a/Documentation/Screenshots/Phone Daily Insulin.png b/Documentation/Screenshots/Phone Daily Insulin.png deleted file mode 100644 index c29ab07df2..0000000000 Binary files a/Documentation/Screenshots/Phone Daily Insulin.png and /dev/null differ diff --git a/Documentation/Screenshots/Phone Statistics.png b/Documentation/Screenshots/Phone Statistics.png deleted file mode 100644 index c04d076974..0000000000 Binary files a/Documentation/Screenshots/Phone Statistics.png and /dev/null differ diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 5230c2ce5e..24f9661b75 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -735,11 +735,7 @@ FA37F6EC10FF02D4F985D724 /* Loop/Resources/Sounds/honk.caf in Resources */ = {isa = PBXBuildFile; fileRef = 6D5FCC7207E50ACC682E9E6E /* Loop/Resources/Sounds/honk.caf */; }; FADE0001000000000000DE02 /* DevelopmentBranchAlerter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADE0001000000000000DE01 /* DevelopmentBranchAlerter.swift */; }; FEEDB0000000000000000000 /* GlucoseStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000000 /* GlucoseStatistics.swift */; }; - FEEDB0000000000000000001 /* StatisticsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000001 /* StatisticsViewModel.swift */; }; - FEEDB0000000000000000002 /* Statistics/StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */; }; - A3C9F155FD92DC188BD9A21F /* Statistics/DailyInsulinChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */; }; - FEEDB0000000000000000003 /* Statistics/AGPChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */; }; - FEEDB0000000000000000004 /* Statistics/TimeInRangeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */; }; + FEEDB0000000000000000004 /* Statistics/GlucoseBand+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000004 /* Statistics/GlucoseBand+Display.swift */; }; 30E4A68B8C914C5E4B245555 /* InsulinDeliveryHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */; }; FEEDB0000000000000000005 /* GlucoseStatisticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */; }; 17EA66859884C0208DED47CF /* LoopKitTypeAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FAEFD820F50BFF211AC6D9 /* LoopKitTypeAliases.swift */; }; @@ -750,6 +746,14 @@ 8DC1CE32F39301122B550325 /* LoopKitTypeAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FAEFD820F50BFF211AC6D9 /* LoopKitTypeAliases.swift */; }; ABD9218EB98C9216FBB04382 /* LoopKitTypeAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FAEFD820F50BFF211AC6D9 /* LoopKitTypeAliases.swift */; }; AF79DFAB0F55143D66022668 /* LoopKitTypeAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FAEFD820F50BFF211AC6D9 /* LoopKitTypeAliases.swift */; }; + 89C0C34F420190E8CC7D03B1 /* DayCharts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F40DE2F3FC02CBA3BE7AF2F /* DayCharts.swift */; }; + 332CC9FC011E78CE5BEA583C /* LoopDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BD3DDF41B598A8C4B5CEB92 /* LoopDesign.swift */; }; + 0C70F4D862F6D3FDB3D91D87 /* LoopHistoryMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21622B2355AED8F6A12FE17C /* LoopHistoryMath.swift */; }; + 2ADBB92A8ABB74360D18EEFA /* LoopHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471FFD525A4EB897745608BE /* LoopHistoryModels.swift */; }; + A743DC26D066EE5AC88C2F45 /* LoopHistoryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09448CF97996C40BEDDE6588 /* LoopHistoryProvider.swift */; }; + 591F13AF435B37B4818E2F2B /* TrendsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BFDE56977CC73CBDA1170C5 /* TrendsComponents.swift */; }; + 5CD0888FF33D6F844A81E638 /* TrendsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF0FD64B2B3E9C9AA8764684 /* TrendsScreen.swift */; }; + 97CF13197936D2794766C84A /* LoopHistoryMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B08644C3031D9E815F9892 /* LoopHistoryMathTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1614,16 +1618,20 @@ F5E0BDD327E1D71C0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Intents.strings; sourceTree = ""; }; FADE0001000000000000DE01 /* DevelopmentBranchAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevelopmentBranchAlerter.swift; sourceTree = ""; }; FEEDF0000000000000000000 /* GlucoseStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatistics.swift; sourceTree = ""; }; - FEEDF0000000000000000001 /* StatisticsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsViewModel.swift; sourceTree = ""; }; - FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/StatisticsView.swift; sourceTree = ""; }; - 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/DailyInsulinChartView.swift; sourceTree = ""; }; - FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/AGPChartView.swift; sourceTree = ""; }; - FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/TimeInRangeBar.swift; sourceTree = ""; }; + FEEDF0000000000000000004 /* Statistics/GlucoseBand+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Statistics/GlucoseBand+Display.swift"; sourceTree = ""; }; F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistoryTests.swift; sourceTree = ""; }; FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatisticsTests.swift; sourceTree = ""; }; 35FAEFD820F50BFF211AC6D9 /* LoopKitTypeAliases.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoopKitTypeAliases.swift; sourceTree = ""; }; 36421A4B9676EF68CDDC4ECD /* AlgorithmBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AlgorithmBridge.swift; sourceTree = ""; }; 6C372374D3D3D9C5F8E3AD93 /* TemporaryScheduleOverrideAlgorithmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverrideAlgorithmTests.swift; sourceTree = ""; }; + 6F40DE2F3FC02CBA3BE7AF2F /* DayCharts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayCharts.swift; sourceTree = ""; }; + 0BD3DDF41B598A8C4B5CEB92 /* LoopDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDesign.swift; sourceTree = ""; }; + 21622B2355AED8F6A12FE17C /* LoopHistoryMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopHistoryMath.swift; sourceTree = ""; }; + 471FFD525A4EB897745608BE /* LoopHistoryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopHistoryModels.swift; sourceTree = ""; }; + 09448CF97996C40BEDDE6588 /* LoopHistoryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopHistoryProvider.swift; sourceTree = ""; }; + 4BFDE56977CC73CBDA1170C5 /* TrendsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsComponents.swift; sourceTree = ""; }; + AF0FD64B2B3E9C9AA8764684 /* TrendsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsScreen.swift; sourceTree = ""; }; + 42B08644C3031D9E815F9892 /* LoopHistoryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopHistoryMathTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1973,6 +1981,7 @@ 43776F8E1B8022E90074EA36 /* Loop */ = { isa = PBXGroup; children = ( + 8574687EF267DC59043C0242 /* Redesign */, C16DA84022E8E104008624C2 /* Plugins */, B66D1F322E6A5D6600471149 /* Localizable.xcstrings */, B66D1F382E6A5D6600471149 /* InfoPlist.xcstrings */, @@ -2251,11 +2260,8 @@ 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */, 0BA918E086710427153DA902 /* ShareOutcomesView.swift */, - FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */, - 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */, - FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */, + FEEDF0000000000000000004 /* Statistics/GlucoseBand+Display.swift */, 174DD37A96993576E0E573C6 /* Statistics/SetupComparisonCharts.swift */, - FEEDF0000000000000000002 /* Statistics/StatisticsView.swift */, E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, @@ -2764,7 +2770,6 @@ 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */, C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, - FEEDF0000000000000000001 /* StatisticsViewModel.swift */, 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */, 3A13D8D000431C52A5899D1E /* ShareOutcomesViewModel.swift */, 1D49795724E7289700948F05 /* ServicesViewModel.swift */, @@ -2872,6 +2877,7 @@ 6C1F8E3BA2D45079B3E2F512 /* OutcomesPackageTests.swift */, BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */, FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */, + 42B08644C3031D9E815F9892 /* LoopHistoryMathTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */, @@ -3052,6 +3058,20 @@ path = Fixtures; sourceTree = ""; }; + 8574687EF267DC59043C0242 /* Redesign */ = { + isa = PBXGroup; + children = ( + AF0FD64B2B3E9C9AA8764684 /* TrendsScreen.swift */, + 4BFDE56977CC73CBDA1170C5 /* TrendsComponents.swift */, + 09448CF97996C40BEDDE6588 /* LoopHistoryProvider.swift */, + 471FFD525A4EB897745608BE /* LoopHistoryModels.swift */, + 21622B2355AED8F6A12FE17C /* LoopHistoryMath.swift */, + 0BD3DDF41B598A8C4B5CEB92 /* LoopDesign.swift */, + 6F40DE2F3FC02CBA3BE7AF2F /* DayCharts.swift */, + ); + path = Redesign; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -3713,6 +3733,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5CD0888FF33D6F844A81E638 /* TrendsScreen.swift in Sources */, + 591F13AF435B37B4818E2F2B /* TrendsComponents.swift in Sources */, + A743DC26D066EE5AC88C2F45 /* LoopHistoryProvider.swift in Sources */, + 2ADBB92A8ABB74360D18EEFA /* LoopHistoryModels.swift in Sources */, + 0C70F4D862F6D3FDB3D91D87 /* LoopHistoryMath.swift in Sources */, + 332CC9FC011E78CE5BEA583C /* LoopDesign.swift in Sources */, + 89C0C34F420190E8CC7D03B1 /* DayCharts.swift in Sources */, C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */, 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */, 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, @@ -3826,7 +3853,6 @@ C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, - FEEDB0000000000000000001 /* StatisticsViewModel.swift in Sources */, 414E7082E6A6B9DA8CD11EF3 /* CurrentSetupViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */, @@ -3902,11 +3928,8 @@ A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, 6D16FF26E7F84F97B5DB6A88 /* CurrentSetupView.swift in Sources */, - FEEDB0000000000000000004 /* Statistics/TimeInRangeBar.swift in Sources */, - A3C9F155FD92DC188BD9A21F /* Statistics/DailyInsulinChartView.swift in Sources */, - FEEDB0000000000000000003 /* Statistics/AGPChartView.swift in Sources */, + FEEDB0000000000000000004 /* Statistics/GlucoseBand+Display.swift in Sources */, 16097A41A1EF24BCF2F670DB /* Statistics/SetupComparisonCharts.swift in Sources */, - FEEDB0000000000000000002 /* Statistics/StatisticsView.swift in Sources */, BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */, A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, 43F89CA322BDFBBD006BB54E /* UIActivityIndicatorView.swift in Sources */, @@ -4186,6 +4209,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 97CF13197936D2794766C84A /* LoopHistoryMathTests.swift in Sources */, A9DF02CB24F72B9E00B7C988 /* CriticalEventLogTests.swift in Sources */, 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */, C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */, diff --git a/Loop/DerivedAssetsBase.xcassets/bolus.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/bolus.colorset/Contents.json new file mode 100644 index 0000000000..30b11e52a8 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/bolus.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.663", + "green": "0.282", + "blue": "0.039", + "alpha": "1.000" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "1.000", + "green": "0.639", + "blue": "0.278", + "alpha": "1.000" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 92601ae0e8..4b942877de 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -12,6 +12,14761 @@ }, " -" : { + }, + "%1$@ above range" : { + "comment" : "Time in range High row: the consensus target for all time above range, high and very high together. (1: target, e.g. < 25%)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ فوق النطاق" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nad rozsahem" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ over området" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ über dem Bereich" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ por encima" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ alueen yläpuolella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ au-dessus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ מעל הטווח" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ tartomány felett" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ sopra" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "範囲上 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ over området" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ boven bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ powyżej zakresu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ acima do alvo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ peste interval" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ выше диапазона" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nad rozsahom" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ över målområdet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aralık üstü %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ вище діапазону" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ trên khoảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高于范围 %1$@" + } + } + } + }, + "%1$@ below range" : { + "comment" : "Time in range Low row: the consensus target for all time below range, low and very low together. (1: target, e.g. < 4%)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ تحت النطاق" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ pod rozsahem" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ under området" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unter dem Bereich" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ por debajo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ alueen alapuolella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ en dessous" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ מתחת לטווח" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ tartomány alatt" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ sotto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "範囲下 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ under området" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ onder bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ poniżej zakresu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ abaixo do alvo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ sub interval" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ниже диапазона" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ pod rozsahom" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ under målområdet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aralık altı %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ нижче діапазону" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ dưới khoảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "低于范围 %1$@" + } + } + } + }, + "%1$@ d" : { + "comment" : "Trends period picker, short label for a period of several days, e.g. 7 d. (1: number of days)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ يوم" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ T" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ pv" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ j" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ימ׳" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nap" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@日" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ z" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ д" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ d" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ д" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@天" + } + } + } + }, + "%1$@ entries" : { + "comment" : "Trends Carbs per day tile detail: average number of carb entries per day, a fractional value such as 2.5, so not pluralised. (1: number with one decimal)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ إدخالات" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "záznamů: %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ registreringer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Einträge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ entradas" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kirjausta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ entrées" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ רשומות" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ bejegyzés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ inserimenti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@件" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ registreringer" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ registraties" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wpisów: %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ registros" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ intrări" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "записей: %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "záznamov: %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ registreringar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ giriş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "записів: %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ lần nhập" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@条" + } + } + } + }, + "%1$@ g" : { + "comment" : "Format string for an amount of carbohydrate in grams. (1: grams)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ gr" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ г" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ g" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 克" + } + } + } + }, + "%1$@ h" : { + "comment" : "Time in range row: average hours a day spent in the band, abbreviated. (1: hours with one decimal)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ س" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ t" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Std" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ שע׳" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ óra" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@時間" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ t" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ u" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ godz." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ч" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ h" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ sa" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ год" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 小时" + } + } + } + }, + "%1$@ manual, %2$@ automatic" : { + "comment" : "VoiceOver: how the bolus divides. (1: manual units, 2: automatic units)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ يدوي، %2$@ تلقائي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ručně, %2$@ automaticky" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuel, %2$@ automatisk" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuell, %2$@ automatisch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manual, %2$@ automático" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuaalinen, %2$@ automaattinen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuel, %2$@ automatique" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ידני, %2$@ אוטומטי" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kézi, %2$@ automatikus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuale, %2$@ automatico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "手動 %1$@、自動 %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuell, %2$@ automatisk" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ handmatig, %2$@ automatisch" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ręcznie, %2$@ automatycznie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manual, %2$@ automático" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manual, %2$@ automat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ вручную, %2$@ автоматически" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ručne, %2$@ automaticky" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuell, %2$@ automatisk" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ manuel, %2$@ otomatik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ вручну, %2$@ автоматично" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ thủ công, %2$@ tự động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动 %1$@,自动 %2$@" + } + } + } + }, + "%1$@ mmol/mol" : { + "comment" : "Trends GMI tile detail: the GMI in IFCC units. (1: value)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ mmol/mol" + } + } + } + }, + "%1$@ of %2$@" : { + "comment" : "Trends Loop active tile detail: time in closed loop out of the time Loop status is known. (1: duration in closed loop, e.g. 6d 4h, 2: duration covered, e.g. 7d) Trends: how many days of the period are averaged. (1: days averaged, a number; 2: days in the period, e.g. 14 days)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ من %2$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ z %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ af %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ von %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ de %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ / %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ sur %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ מתוך %2$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ / %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ su %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ / %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ av %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ van %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ z %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ de %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ din %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ из %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ z %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ av %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ / %2$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ з %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ / %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ / %2$@" + } + } + } + }, + "%1$@ of unknown kind\n+ %2$@ automatic" : { + "comment" : "Trends Boluses per day tile detail when some boluses don't say whether they were manual or automatic, such as some in Apple Health, on two lines: how many of the figure above are of unknown kind, then the automatic boluses on top. (1: boluses of unknown kind per day; 2: automatic boluses per day; both fractional, such as 4.2)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ من نوع غير معروف\n+ %2$@ تلقائية" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ neznámého druhu\n+ %2$@ automatických" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ af ukendt type\n+ %2$@ automatiske" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unbekannter Art\n+ %2$@ automatisch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ de tipo desconocido\n+ %2$@ automáticos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ tuntematonta tyyppiä\n+ %2$@ automaattista" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ de type inconnu\n+ %2$@ automatiques" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ מסוג לא ידוע\n+ %2$@ אוטומטיים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ismeretlen típusú\n+ %2$@ automatikus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ di tipo sconosciuto\n+ %2$@ automatici" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "種類不明 %1$@\n+ 自動 %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ av ukjent type\n+ %2$@ automatiske" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ van onbekend type\n+ %2$@ automatisch" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nieznanego rodzaju\n+ %2$@ automatycznych" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ de tipo desconhecido\n+ %2$@ automáticos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ de tip necunoscut\n+ %2$@ automate" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ неизвестного типа\n+ %2$@ автоматических" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ neznámeho druhu\n+ %2$@ automatických" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ av okänd typ\n+ %2$@ automatiska" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ bilinmeyen türde\n+ %2$@ otomatik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ невідомого типу\n+ %2$@ автоматичних" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ không rõ loại\n+ %2$@ tự động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类型未知 %1$@\n+ 自动 %2$@" + } + } + } + }, + "%1$@ U/h" : { + "comment" : "Format string for an insulin rate in units per hour. (1: rate)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ وحدة لكل ساعة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/hod" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E/t" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ IE/Std" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/h" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/h" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/hr %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/óra" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/ora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 単位/時" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E/t" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E/uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ J/h" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/h" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/oră" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ед/ч" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ J/hod" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ IE/h" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ü/Sa" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/год" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/小时" + } + } + } + }, + "%1$@ unknown" : { + "comment" : "Basal and bolus: bolus whose record doesn't say whether it was manual or automatic, such as some in Apple Health, after the manual and automatic amounts. (1: units, e.g. 3.2 U)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ غير معروف" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ neznámý" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ukendt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unbekannt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ desconocido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ tuntematon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ inconnu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ לא ידוע" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ismeretlen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ sconosciuto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ukjent" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ onbekend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nieznany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ desconhecido" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ necunoscut" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ неизвестно" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ neznámy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ okänd" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ bilinmeyen" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ невідомо" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ không rõ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知 %1$@" + } + } + } + }, + "%1$@%%" : { + "comment" : "A percentage. (1: number)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %%" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %%" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %%" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %%" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%%%1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%%" + } + } + } + }, + "%1$@%% CGM active" : { + "comment" : "Trends Time in range card: share of the period with CGM readings. (1: percentage number)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%% CGM نشط" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM aktivní %1$@%%" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %% CGM aktiv" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %% CGM aktiv" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM activo %1$@%%" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %% CGM aktiivinen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM actif %1$@ %%" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%% CGM פעיל" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%% CGM aktív" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM attivo %1$@%%" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM稼働 %1$@%%" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %% CGM aktiv" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%% CGM actief" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM aktywny %1$@%%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM ativo %1$@%%" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%% CGM activ" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM активен %1$@%%" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM aktívny %1$@%%" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %% CGM aktiv" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%%%1$@ CGM aktif" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM активний %1$@%%" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM hoạt động %1$@%%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM 在线 %1$@%%" + } + } + } + }, + "%1$@, %2$@ %3$@: %4$@ percent, %5$@ hours a day" : { + "comment" : "VoiceOver label for a Time in range row. (1: band name, e.g. In range, 2: glucose thresholds, e.g. 3.9–10.0, 3: glucose unit, 4: percentage number, 5: average hours a day with one decimal)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@، %2$@ %3$@: %4$@ بالمئة، %5$@ ساعة يوميًا" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: procenta — %4$@, hodin denně — %5$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ procent, %5$@ timer om dagen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ Prozent, %5$@ Stunden pro Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ por ciento, %5$@ horas al día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ prosenttia, %5$@ tuntia päivässä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@ : %4$@ pour cent, %5$@ heures par jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ אחוז, %5$@ שעות ביום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ százalék, napi %5$@ óra" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ percento, %5$@ ore al giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@、%2$@ %3$@:%4$@パーセント、1日%5$@時間" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ prosent, %5$@ timer i døgnet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ procent, %5$@ uur per dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: procent — %4$@, godzin dziennie — %5$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ por cento, %5$@ horas por dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ procente, %5$@ ore pe zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: процент — %4$@, часов в день — %5$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: percentá — %4$@, hodín denne — %5$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ procent, %5$@ timmar per dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: yüzde %4$@, günde %5$@ saat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: відсоток — %4$@, годин на день — %5$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@ %3$@: %4$@ phần trăm, %5$@ giờ mỗi ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@,%2$@ %3$@:百分之%4$@,每天%5$@小时" + } + } + } + }, + "%lld days" : { + "comment" : "A number of days, e.g. a Trends period or how many days are averaged. (1: number of days) Trends period picker, VoiceOver name for a period of several days. (1: number of days)", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days" + } + } + } + } + }, + "ar" : { + "variations" : { + "plural" : { + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld يوم" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld يوم" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld يوم" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld أيام" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld يومًا" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld يوم" + } + } + } + } + }, + "cs" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld den" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dny" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dne" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dní" + } + } + } + } + }, + "da" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dage" + } + } + } + } + }, + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Tag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Tage" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld día" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld días" + } + } + } + } + }, + "fi" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld päivä" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld päivää" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld jour" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld jours" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld jours" + } + } + } + } + }, + "he" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld יום" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ימים" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ימים" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ימים" + } + } + } + } + }, + "hu" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nap" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nap" + } + } + } + } + }, + "it" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld giorno" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld giorni" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld日" + } + } + } + } + }, + "nb" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dager" + } + } + } + } + }, + "nl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dagen" + } + } + } + } + }, + "pl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dzień" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dni" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dni" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dnia" + } + } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dia" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dias" + } + } + } + } + }, + "ro" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld zi" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld zile" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld de zile" + } + } + } + } + }, + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld день" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld дня" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld дней" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld дня" + } + } + } + } + }, + "sk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld deň" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dni" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dňa" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dní" + } + } + } + } + }, + "sv" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dagar" + } + } + } + } + }, + "tr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld gün" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld gün" + } + } + } + } + }, + "uk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld день" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld дні" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld днів" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld дня" + } + } + } + } + }, + "vi" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ngày" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld天" + } + } + } + } + } + } + }, + "%lld entries" : { + "comment" : "Trends Carbs tile detail for a one-day period: how many carb entries were made that day. (1: number)", + "localizations" : { + "ar" : { + "variations" : { + "plural" : { + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld إدخالات" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "إدخال واحد" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "إدخالان" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld إدخالات" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld إدخالًا" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld إدخال" + } + } + } + } + }, + "cs" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznam" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznamy" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznamu" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznamů" + } + } + } + } + }, + "da" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registrering" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registreringer" + } + } + } + } + }, + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Eintrag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Einträge" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entry" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entries" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entrada" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entradas" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entradas" + } + } + } + } + }, + "fi" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kirjaus" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kirjausta" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entrée" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entrées" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entrées" + } + } + } + } + }, + "he" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "רשומה אחת" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld רשומות" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld רשומות" + } + } + } + } + }, + "hu" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bejegyzés" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bejegyzés" + } + } + } + } + }, + "it" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld inserimento" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld inserimenti" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld inserimenti" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld件" + } + } + } + } + }, + "nb" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registrering" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registreringer" + } + } + } + } + }, + "nl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registratie" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registraties" + } + } + } + } + }, + "pl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld wpis" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld wpisy" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld wpisów" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld wpisu" + } + } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registro" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registros" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registros" + } + } + } + } + }, + "ro" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld intrare" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld intrări" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld de intrări" + } + } + } + } + }, + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld запись" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld записи" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld записей" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld записи" + } + } + } + } + }, + "sk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznam" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznamy" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznamu" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld záznamov" + } + } + } + } + }, + "sv" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registrering" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld registreringar" + } + } + } + } + }, + "tr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld giriş" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld giriş" + } + } + } + } + }, + "uk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld запис" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld записи" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld записів" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld запису" + } + } + } + } + }, + "vi" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld lần nhập" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld条" + } + } + } + } + } + } + }, + "%lld shorter gaps" : { + "comment" : "Chart note: how many other, shorter gaps in glucose readings there were; follows the longest gap. (1: number of gaps)", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld shorter gap" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld shorter gaps" + } + } + } + } + }, + "ar" : { + "variations" : { + "plural" : { + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld فجوة أقصر" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld فجوة أقصر" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld فجوة أقصر" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld فجوات أقصر" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld فجوة أقصر" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld فجوة أقصر" + } + } + } + } + }, + "cs" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratší výpadek" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratší výpadky" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratšího výpadku" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratších výpadků" + } + } + } + } + }, + "da" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortere hul" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortere huller" + } + } + } + } + }, + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kürzere Lücke" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kürzere Lücken" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interrupción más corta" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interrupciones más cortas" + } + } + } + } + }, + "fi" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld lyhyempi katko" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld lyhyempää katkoa" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interruption plus courte" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interruptions plus courtes" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interruptions plus courtes" + } + } + } + } + }, + "he" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld פער קצר יותר" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld פערים קצרים יותר" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld פערים קצרים יותר" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld פערים קצרים יותר" + } + } + } + } + }, + "hu" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld rövidebb kimaradás" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld rövidebb kimaradás" + } + } + } + } + }, + "it" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interruzione più breve" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interruzioni più brevi" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "ほか短い欠損%lld件" + } + } + } + } + }, + "nb" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortere hull" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortere hull" + } + } + } + } + }, + "nl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortere onderbreking" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortere onderbrekingen" + } + } + } + } + }, + "pl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld krótsza przerwa" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld krótsze przerwy" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld krótszych przerw" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld krótszej przerwy" + } + } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interrupção mais curta" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld interrupções mais curtas" + } + } + } + } + }, + "ro" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld întrerupere mai scurtă" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld întreruperi mai scurte" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld de întreruperi mai scurte" + } + } + } + } + }, + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld короткий пропуск" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld коротких пропуска" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld коротких пропусков" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld коротких пропуска" + } + } + } + } + }, + "sk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratší výpadok" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratšie výpadky" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratšieho výpadku" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kratších výpadkov" + } + } + } + } + }, + "sv" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortare lucka" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kortare luckor" + } + } + } + } + }, + "tr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld daha kısa boşluk" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld daha kısa boşluk" + } + } + } + } + }, + "uk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld коротший пропуск" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld коротші пропуски" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld коротших пропусків" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld коротшого пропуску" + } + } + } + } + }, + "vi" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld khoảng trống ngắn hơn" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "另有%lld段较短中断" + } + } + } + } + } + } + }, + "+ %1$@ automatic" : { + "comment" : "Trends Boluses per day tile detail: average number of automatic boluses per day on top of the manual ones, a fractional value such as 4.2. (1: number with one decimal)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ تلقائي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automat." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatiske" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatisch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automáticos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automaattista" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatiques" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ אוטומטיים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatikus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatici" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ 自動 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatiske" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatisch" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automat." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automáticos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automate" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ авто" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automat." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ automatiska" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ otomatik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ авто" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ %1$@ tự động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "+ 自动 %1$@" + } + } + } + }, + "25–75%" : { + "comment" : "Trends Daily pattern legend: band between the 25th and 75th percentiles of glucose", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75 %" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75 %" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75 %" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75 %" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%25–75" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "25–75%" + } + } + } + }, + "5–95%" : { + "comment" : "Trends Daily pattern legend: band between the 5th and 95th percentiles of glucose", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95 %" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95 %" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95 %" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95 %" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%5–95" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5–95%" + } + } + } + }, + "aim %1$@" : { + "comment" : "Trends: the consensus target under a figure, in lower case. (1: target, e.g. < 7%)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الهدف %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "cíl %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mål %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ziel %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "objetivo %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tavoite %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "objectif %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יעד %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "cél: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "obiettivo %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mål %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "doel %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "cel %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "meta %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "țintă %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "цель %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "cieľ %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mål %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "hedef %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "мета %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mục tiêu %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标 %1$@" + } + } + } + }, + "aim %1$@, CV %2$@" : { + "comment" : "Trends Average tile: the consensus targets for average glucose and its coefficient of variation, in lower case. (1: glucose target, e.g. < 8.6; 2: CV target, e.g. ≤ 36%)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الهدف %1$@، CV %2$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "cíl %1$@, CV %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mål %1$@, CV %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ziel %1$@, VK %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "objetivo %1$@, CV %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tavoite %1$@, CV %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "objectif %1$@, CV %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יעד %1$@, CV %2$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "cél: %1$@, CV %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "obiettivo %1$@, CV %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標 %1$@、CV %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mål %1$@, CV %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "doel %1$@, CV %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "cel %1$@, CV %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "meta %1$@, CV %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "țintă %1$@, CV %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "цель %1$@, CV %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "cieľ %1$@, CV %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mål %1$@, CV %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "hedef %1$@, CV %2$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "мета %1$@, CV %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mục tiêu %1$@, CV %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标 %1$@,CV %2$@" + } + } + } + }, + "Automatic" : { + "comment" : "Chart legend: hatched bars are automatic boluses given by Loop", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تلقائي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatické" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automático" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaattinen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatique" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אוטומטי" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatikus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatyczne" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automático" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматические" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatické" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otomatik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматичні" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动" + } + } + } + }, + "Automatic %1$@" : { + "comment" : "Basal and bolus: bolus Loop gave automatically. (1: units, e.g. 5.4 U)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تلقائي %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaticky %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automático %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaattinen %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatique %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אוטומטי %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatikus %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatico %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatycznie %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automático %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automat %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматически %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaticky %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otomatik %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматично %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动 %1$@" + } + } + } + }, + "Automatic bolus" : { + "comment" : "Chart callout label for a bolus Loop delivered automatically near the touched time Logbook row title: a bolus Loop delivered automatically", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus تلقائي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatický bolus" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatischer Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo automático" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaattinen bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus automatique" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus אוטומטי" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatikus bólus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo automatico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動ボーラス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatische bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus automatyczny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus automático" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus automat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматический болюс" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatický bolus" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otomatik bolus" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматичний болюс" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus tự động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动大剂量" + } + } + } + }, + "average of %1$@" : { + "comment" : "Trends Insulin per day card subtitle when only some days of the period are averaged, such as a day still in progress. (1: days averaged, e.g. 13 of 14 days)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "متوسط %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "průměr za %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "gennemsnit af %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durchschnitt aus %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "promedio de %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "keskiarvo, %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "moyenne sur %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ממוצע של %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "átlag, %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "media su %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@の平均" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "gjennomsnitt av %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gemiddelde over %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "średnia z %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "média de %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "medie pe %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "среднее за %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "priemer za %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "genomsnitt av %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ortalaması" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "середнє за %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trung bình của %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@的平均值" + } + } + } + }, + "average per day" : { + "comment" : "Trends Basal and bolus card subtitle when the figures are averaged per day over the period", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المتوسط اليومي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "průměr za den" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "gennemsnit pr. dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durchschnitt pro Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "promedio por día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "keskiarvo päivässä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "moyenne par jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ממוצע ליום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "napi átlag" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "media al giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日あたりの平均" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "gjennomsnitt per dag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gemiddeld per dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "średnio dziennie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "média por dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "medie pe zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в среднем за день" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "priemer za deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "medel per dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "günlük ortalama" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "у середньому за день" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trung bình mỗi ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日平均" + } + } + } + }, + "Basal" : { + "comment" : "Chart callout label for the basal rate at the touched time Label of a tile showing the current basal rate (Now screen) or the day's basal insulin total (Today so far totals) Logbook filter chip: show only basal changes", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "القاعدية" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazál" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaalinen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ベーサル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podstawowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazală" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Базал" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazálna" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazal" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Базал" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基础" + } + } + } + }, + "Basal %1$@, %2$@ percent" : { + "comment" : "VoiceOver: basal units and share. (1: units, 2: percentage number)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "القاعدية %1$@، %2$@ بالمئة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazál %1$@, %2$@ procent" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ procent" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ Prozent" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ por ciento" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaali %1$@, %2$@ prosenttia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ pour cent" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ אחוז" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ százalék" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basale %1$@, %2$@ percento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ベーサル %1$@、%2$@ パーセント" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ prosent" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaal %1$@, %2$@ procent" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podstawowy %1$@, %2$@ procent" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ por cento" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazală %1$@, %2$@ la sută" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Базал %1$@, %2$@ процентов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazál %1$@, %2$@ percent" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ procent" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazal %1$@, yüzde %2$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Базал %1$@, %2$@ відсотків" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal %1$@, %2$@ phần trăm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基础 %1$@,百分之 %2$@" + } + } + } + }, + "Basal given" : { + "comment" : "Chart legend: filled area is basal insulin actually delivered", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "القاعدية المُعطاة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podaný bazál" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Givet basal" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abgegebene Basalrate" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal administrada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annettu basaali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal administré" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal שניתן" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leadott basal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basale erogata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "注入済みベーサル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gitt basal" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toegediend basaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podana baza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal entregue" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazală administrată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поданный базал" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podaný bazál" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Given basal" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verilen bazal" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поданий базал" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal đã tiêm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已输注基础" + } + } + } + }, + "Best over 14 days with %1$@ CGM data" : { + "comment" : "Trends GMI tile caption when the period is shorter than 14 days or CGM readings cover less than 70% of it: the consensus for when GMI is representative. (1: share of CGM data, e.g. 70%)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأفضل على مدى 14 يومًا مع %1$@ من بيانات CGM" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nejlépe za 14 dní s %1$@ dat CGM" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bedst over 14 dage med %1$@ CGM-data" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Am besten über 14 Tage mit %1$@ CGM-Daten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mejor en 14 días con %1$@ de datos de CGM" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paras 14 päivän ajalta, kun CGM-dataa on %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idéalement sur 14 jours avec %1$@ de données CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מומלץ על פני 14 ימים עם %1$@ נתוני CGM" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legjobb 14 nap alatt, %1$@ CGM-adattal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ideale su 14 giorni con il %1$@ di dati CGM" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "14日間・CGMデータ%1$@以上が目安" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Best over 14 dager med %1$@ CGM-data" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het best over 14 dagen met %1$@ CGM-gegevens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Najlepiej z 14 dni z %1$@ danych CGM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ideal em 14 dias com %1$@ de dados de CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ideal pe 14 zile cu %1$@ date CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лучше за 14 дней с %1$@ данных CGM" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Najlepšie za 14 dní s %1$@ údajov CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bäst över 14 dagar med %1$@ CGM-data" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En iyisi %1$@ CGM verisiyle 14 gün" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найкраще за 14 днів із %1$@ даних CGM" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tốt nhất trong 14 ngày với %1$@ dữ liệu CGM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最好基于 14 天、%1$@ 的 CGM 数据" + } + } + } + }, + "Bolus %1$@, %2$@ percent" : { + "comment" : "VoiceOver: bolus units and share. (1: units, 2: percentage number)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "البلعة %1$@، %2$@ بالمئة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ procent" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ procent" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ Prozent" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo %1$@, %2$@ por ciento" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ prosenttia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ pour cent" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ אחוז" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bólus %1$@, %2$@ százalék" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo %1$@, %2$@ percento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス %1$@、%2$@ パーセント" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ prosent" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ procent" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ procent" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ por cento" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ la sută" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс %1$@, %2$@ процентов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ percent" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ procent" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, yüzde %2$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс %1$@, %2$@ відсотків" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@, %2$@ phần trăm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量 %1$@,百分之 %2$@" + } + } + } + }, + "Boluses" : { + "comment" : "Trends summary tile for a one-day period: how many boluses were given that day, other than Loop's automatic ones", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الجرعات" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusy" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boluser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boli" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boluksia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוסים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bóluszok" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boluser" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolussen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusuri" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюсы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusdoser" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюси" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liều bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量次数" + } + } + } + }, + "Boluses per day" : { + "comment" : "Trends summary tile: average number of boluses per day, other than Loop's automatic ones", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جرعات Bolus يوميًا" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusů denně" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boluser pr. dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boli pro Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolos por día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boluksia päivässä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus par jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus ליום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bólusok naponta" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boli al giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日のボーラス回数" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boluser per dag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolussen per dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusy dziennie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus por dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusuri pe zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюсов в день" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusy za deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusar per dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük bolus sayısı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюсів на день" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số lần Bolus mỗi ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日大剂量次数" + } + } + } + }, + "Carbs" : { + "comment" : "Label of a tile showing the day's total carbohydrate Logbook filter chip: show only carb entries Logbook row title: a carb entry", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sacharidy" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraatit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Szénhidrát" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carb." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カーボ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вуглеводи" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水化合物" + } + } + } + }, + "Carbs per day" : { + "comment" : "Trends summary tile: average grams of carbohydrate entered per day", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الكارب يوميًا" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sacharidy denně" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydrater pr. dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH pro Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos por día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraatit päivässä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides par jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות ליום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Szénhidrát naponta" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati al giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日のカーボ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydrater per dag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraten per dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Węglowodany dziennie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs por dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați pe zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Углеводов в день" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sacharidy za deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydrater per dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük karb" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вуглеводів на день" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs mỗi ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日碳水" + } + } + } + }, + "CGM data covers %1$@." : { + "comment" : "Trends Summary note when glucose readings start partway through the period. (1: days covered, e.g. 5 of 14 days)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بيانات CGM تغطي %1$@." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data CGM pokrývají %1$@." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-data dækker %1$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-Daten decken %1$@ ab." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los datos de CGM cubren %1$@." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-data kattaa %1$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données CGM couvrent %1$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נתוני CGM מכסים %1$@." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-adat: %1$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati CGM coprono %1$@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGMデータ:%1$@。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-data dekker %1$@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-gegevens beslaan %1$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane CGM obejmują %1$@." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os dados de CGM cobrem %1$@." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datele CGM acoperă %1$@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные CGM: %1$@." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Údaje CGM pokrývajú %1$@." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-data täcker %1$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM verisi: %1$@." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дані CGM: %1$@." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu CGM bao phủ %1$@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM 数据覆盖 %1$@。" + } + } + } + }, + "Closed loop" : { + "comment" : "Trends Loop status: time Loop was running in closed loop", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حلقة مغلقة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzavřená smyčka" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circuito cerrado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suljettu säätö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boucle fermée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zárt hurok" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クローズドループ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve loop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla zamknięta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo fechado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buclă închisă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутый цикл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzavretá slučka" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluten loop" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı döngü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутий цикл" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vòng lặp kín" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环" + } + } + } + }, + "COB" : { + "comment" : "Abbreviation for carbs on board (active carbohydrate) Abbreviation for carbs on board; label of a tile on the Now screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GA" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "COB" + } + } + } + }, + "Couldn't load Trends: %1$@" : { + "comment" : "Trends error message when the history can't be read. (1: error description)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر تحميل الاتجاهات: %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze načíst trendy: %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke indlæse Tendenser: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trends konnten nicht geladen werden: %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudieron cargar las tendencias: %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendejä ei voitu ladata: %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de charger les tendances : %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן היה לטעון מגמות: %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nem sikerült betölteni a trendeket: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile caricare le tendenze: %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トレンドを読み込めませんでした:%1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke laste inn Trender: %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trends kunnen niet worden geladen: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się wczytać trendów: %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não foi possível carregar as tendências: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu s-au putut încărca tendințele: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить тренды: %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendy sa nedajú načítať: %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunde inte läsa in Trender: %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eğilimler yüklenemedi: %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося завантажити тренди: %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể tải Xu hướng: %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法载入趋势:%1$@" + } + } + } + }, + "Covers %1$@, from the start of Loop's records, which it keeps for %2$@." : { + "comment" : "Trends Loop status footnote when Loop's records of its runs and settings start partway through the period. (1: date range covered; 2: how long Loop keeps them, e.g. 90 days)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يغطي %1$@، من بداية سجلات Loop التي يحتفظ بها لمدة %2$@." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokrývá %1$@, od začátku záznamů Loopu, které uchovává %2$@." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dækker %1$@, fra starten af Loops registreringer, som gemmes i %2$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deckt %1$@ ab, ab Beginn der Aufzeichnungen von Loop, die %2$@ lang gespeichert werden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cubre %1$@, desde el inicio de los registros de Loop, que se conservan durante %2$@." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kattaa %1$@, Loopin tietojen alusta; Loop säilyttää niitä %2$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couvre %1$@, depuis le début des enregistrements de Loop, conservés pendant %2$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מכסה %1$@, מתחילת הרישומים של Loop, שנשמרים למשך %2$@." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lefedi: %1$@, a Loop rögzítéseinek kezdetétől; a Loop %2$@ időtartamig őrzi meg őket." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copre %1$@, dall'inizio delle registrazioni di Loop, conservate per %2$@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@を表示(Loopの記録の開始以降。Loopは%2$@保存します)。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dekker %1$@, fra starten av Loops registreringer, som lagres i %2$@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beslaat %1$@, vanaf het begin van de gegevens van Loop, die %2$@ worden bewaard." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obejmuje %1$@, od początku zapisów Loop, które są przechowywane przez %2$@." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cobre %1$@, desde o início dos registros do Loop, mantidos por %2$@." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acoperă %1$@, de la începutul înregistrărilor Loop, păstrate %2$@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Охватывает %1$@, с начала записей Loop, которые хранятся %2$@." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokrýva %1$@, od začiatku záznamov Loopu, ktoré uchováva %2$@." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Täcker %1$@, från början av Loops registreringar, som sparas i %2$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ dönemini kapsar; Loop kayıtlarının başlangıcından itibaren, kayıtlar %2$@ saklanır." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Охоплює %1$@, від початку записів Loop, які зберігаються %2$@." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bao gồm %1$@, từ khi bắt đầu dữ liệu của Loop, được lưu trong %2$@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "涵盖 %1$@,从 Loop 记录开始起;Loop 会保留这些记录 %2$@。" + } + } + } + }, + "Daily pattern" : { + "comment" : "Trends card title: the ambulatory glucose profile, glucose by time of day across the period", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "النمط اليومي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denní profil" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Døgnmønster" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tagesprofil" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Patrón diario" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuorokausiprofiili" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil journalier" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דפוס יומי" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Napi mintázat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Andamento giornaliero" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日内パターン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Døgnmønster" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dagpatroon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil dobowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Padrão diário" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipar zilnic" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Суточный профиль" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denný profil" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dygnsmönster" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük örüntü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добовий профіль" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mẫu hằng ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日模式" + } + } + } + }, + "Daily pattern over %1$@. Median highest %2$@ at %3$@, lowest %4$@ at %5$@" : { + "comment" : "VoiceOver label for the Trends Daily pattern chart. (1: days with readings, e.g. 14 days; 2: highest median glucose; 3: time of day; 4: lowest median glucose; 5: time of day)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "النمط اليومي خلال %1$@. أعلى وسيط %2$@ عند %3$@، وأدنى وسيط %4$@ عند %5$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denní profil za %1$@. Nejvyšší medián %2$@ v %3$@, nejnižší %4$@ v %5$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Døgnmønster over %1$@. Median højest %2$@ kl. %3$@, lavest %4$@ kl. %5$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tagesprofil über %1$@. Median am höchsten %2$@ um %3$@, am niedrigsten %4$@ um %5$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Patrón diario de %1$@. Mediana más alta %2$@ a las %3$@, más baja %4$@ a las %5$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuorokausiprofiili %1$@ ajalta. Korkein mediaani %2$@ klo %3$@, matalin %4$@ klo %5$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil journalier sur %1$@. Médiane la plus haute %2$@ à %3$@, la plus basse %4$@ à %5$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דפוס יומי במשך %1$@. חציון גבוה ביותר %2$@ ב-%3$@, נמוך ביותר %4$@ ב-%5$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Napi mintázat, %1$@. Legmagasabb medián %2$@ ekkor: %3$@, legalacsonyabb %4$@ ekkor: %5$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Andamento giornaliero su %1$@. Mediana più alta %2$@ alle %3$@, più bassa %4$@ alle %5$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@の日内パターン。中央値の最高は%3$@の%2$@、最低は%5$@の%4$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Døgnmønster over %1$@. Median høyest %2$@ kl. %3$@, lavest %4$@ kl. %5$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dagpatroon over %1$@. Mediaan hoogst %2$@ om %3$@, laagst %4$@ om %5$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil dobowy z %1$@. Najwyższa mediana %2$@ o %3$@, najniższa %4$@ o %5$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Padrão diário em %1$@. Mediana mais alta %2$@ às %3$@, mais baixa %4$@ às %5$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipar zilnic pe %1$@. Mediana cea mai mare %2$@ la %3$@, cea mai mică %4$@ la %5$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Суточный профиль за %1$@. Наибольшая медиана %2$@ в %3$@, наименьшая %4$@ в %5$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denný profil za %1$@. Najvyšší medián %2$@ o %3$@, najnižší %4$@ o %5$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dygnsmönster över %1$@. Högsta median %2$@ kl. %3$@, lägsta %4$@ kl. %5$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ boyunca günlük örüntü. En yüksek medyan %3$@ saatinde %2$@, en düşük %5$@ saatinde %4$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добовий профіль за %1$@. Найвища медіана %2$@ о %3$@, найнижча %4$@ о %5$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mẫu hằng ngày trong %1$@. Trung vị cao nhất %2$@ lúc %3$@, thấp nhất %4$@ lúc %5$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@的每日模式。中位数最高 %2$@(%3$@),最低 %4$@(%5$@)" + } + } + } + }, + "Daily pattern: not enough days" : { + "comment" : "VoiceOver label for the Trends Daily pattern chart when fewer than 5 days have readings", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "النمط اليومي: لا توجد أيام كافية" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denní profil: málo dní" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Døgnmønster: ikke nok dage" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tagesprofil: nicht genug Tage" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Patrón diario: no hay suficientes días" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuorokausiprofiili: liian vähän päiviä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil journalier : pas assez de jours" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דפוס יומי: אין מספיק ימים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Napi mintázat: nincs elég nap" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Andamento giornaliero: giorni insufficienti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日内パターン:日数が不足しています" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Døgnmønster: ikke nok dager" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dagpatroon: niet genoeg dagen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil dobowy: za mało dni" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Padrão diário: dias insuficientes" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipar zilnic: prea puține zile" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Суточный профиль: недостаточно дней" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denný profil: málo dní" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dygnsmönster: för få dagar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük örüntü: yeterli gün yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добовий профіль: замало днів" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mẫu hằng ngày: không đủ số ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日模式:天数不足" + } + } + } + }, + "Day" : { + "comment" : "Appearance option and app icon name: the light (daytime) version", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نهار" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Päivä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nap" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dzień" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "День" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gündüz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "День" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + } + } + }, + "Day (Trends period)" : { + "comment" : "Trends period picker: a period of one calendar day (not daytime)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يوم" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Päivä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nap" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dzień" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "День" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gün" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "День" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + } + } + }, + "In range" : { + "comment" : "Time in range band: glucose 70–180 mg/dL (3.9–10.0 mmol/L) Time-in-range band name: glucose 70–180 mg/dL (3.9–10.0 mmol/L)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ضمن النطاق" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "V rozsahu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I området" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im Zielbereich" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En rango" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tavoitealueella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dans la cible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטווח" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tartományban" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nell'intervallo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "範囲内" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "I målområdet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Binnen bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "W zakresie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "No alvo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În interval" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В диапазоне" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "V rozsahu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inom målområdet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aralıkta" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "У діапазоні" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trong khoảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "范围内" + } + } + } + }, + "Insulin per day" : { + "comment" : "Trends tile and card title: average total insulin per day", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأنسولين يوميًا" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín za den" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin pr. dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin pro Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina por día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliini päivässä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline par jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין ליום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulin naponta" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina al giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日のインスリン量" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin per dag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline per dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina dziennie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina por dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină pe zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инсулин в день" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín za deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin per dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük insülin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Інсулін на день" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin mỗi ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日胰岛素" + } + } + } + }, + "Insulin per day, average %1$@" : { + "comment" : "VoiceOver label for the Trends Insulin per day chart. (1: average insulin per day, e.g. 32.1 U)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأنسولين يوميًا، المتوسط %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín za den, průměr %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin pr. dag, gennemsnit %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin pro Tag, Durchschnitt %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina por día, promedio %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliini päivässä, keskiarvo %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline par jour, moyenne %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין ליום, ממוצע %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulin naponta, átlag %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina al giorno, media %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日のインスリン量、平均 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin per dag, gjennomsnitt %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline per dag, gemiddeld %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina dziennie, średnio %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina por dia, média %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină pe zi, medie %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инсулин в день, в среднем %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín za deň, priemer %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin per dag, medel %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük insülin, ortalama %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Інсулін на день, у середньому %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin mỗi ngày, trung bình %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日胰岛素,平均 %1$@" + } + } + } + }, + "Insulin per day, weekly average, average %1$@" : { + "comment" : "VoiceOver label for the Trends Insulin per day chart when bars are weekly averages. (1: average insulin per day, e.g. 32.1 U)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأنسولين يوميًا، متوسط أسبوعي، المتوسط %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín za den, týdenní průměr, průměr %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin pr. dag, ugegennemsnit, gennemsnit %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin pro Tag, Wochendurchschnitt, Durchschnitt %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina por día, promedio semanal, promedio %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliini päivässä, viikkokeskiarvo, keskiarvo %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline par jour, moyenne hebdomadaire, moyenne %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין ליום, ממוצע שבועי, ממוצע %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulin naponta, heti átlag, átlag %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina al giorno, media settimanale, media %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日のインスリン量、週平均、平均 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin per dag, ukesgjennomsnitt, gjennomsnitt %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline per dag, weekgemiddelde, gemiddeld %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina dziennie, średnia tygodniowa, średnio %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina por dia, média semanal, média %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină pe zi, medie săptămânală, medie %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инсулин в день, среднее за неделю, в среднем %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín za deň, týždenný priemer, priemer %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin per dag, veckomedel, medel %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük insülin, haftalık ortalama, ortalama %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Інсулін на день, середнє за тиждень, у середньому %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin mỗi ngày, trung bình theo tuần, trung bình %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日胰岛素,按周平均,平均 %1$@" + } + } + } + }, + "Insulin timeline, %1$@, bolus %2$@, basal %3$@, carbs %4$@" : { + "comment" : "VoiceOver summary of the day's insulin timeline chart with no glucose readings. (1: weekday and date; 2: bolus total, e.g. 12.5 U; 3: basal total; 4: carbs total, e.g. 140 g)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مخطط الأنسولين، %1$@، Bolus %2$@، القاعدية %3$@، الكارب %4$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Časová osa inzulínu, %1$@, bolus %2$@, bazál %3$@, sacharidy %4$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintidslinje, %1$@, bolus %2$@, basal %3$@, kulhydrater %4$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin-Zeitleiste, %1$@, Bolus %2$@, Basal %3$@, KH %4$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cronología de insulina, %1$@, bolo %2$@, basal %3$@, carbohidratos %4$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliinin aikajana, %1$@, bolus %2$@, basaali %3$@, hiilihydraatit %4$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chronologie de l'insuline, %1$@, bolus %2$@, basal %3$@, glucides %4$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ציר זמן אינסולין, %1$@, Bolus %2$@, Basal %3$@, פחמימות %4$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulin idővonal, %1$@, bólus %2$@, basal %3$@, szénhidrát %4$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cronologia insulina, %1$@, bolo %2$@, basale %3$@, carboidrati %4$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インスリンのタイムライン、%1$@、ボーラス %2$@、ベーサル %3$@、カーボ %4$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintidslinje, %1$@, bolus %2$@, basal %3$@, karbohydrater %4$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinetijdlijn, %1$@, bolus %2$@, basaal %3$@, koolhydraten %4$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oś czasu insuliny, %1$@, bolus %2$@, baza %3$@, węglowodany %4$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linha do tempo de insulina, %1$@, bolus %2$@, basal %3$@, carbs %4$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cronologie insulină, %1$@, bolus %2$@, bazală %3$@, carbohidrați %4$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хронология инсулина, %1$@, болюс %2$@, базал %3$@, углеводы %4$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Časová os inzulínu, %1$@, bolus %2$@, bazál %3$@, sacharidy %4$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintidslinje, %1$@, bolus %2$@, basal %3$@, kolhydrater %4$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin zaman çizelgesi, %1$@, bolus %2$@, bazal %3$@, karb %4$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хронологія інсуліну, %1$@, болюс %2$@, базал %3$@, вуглеводи %4$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dòng thời gian insulin, %1$@, Bolus %2$@, Basal %3$@, Carbs %4$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素时间线,%1$@,大剂量 %2$@,基础 %3$@,碳水 %4$@" + } + } + } + }, + "Insulin timeline, %1$@, bolus %2$@, basal %3$@, carbs %4$@, latest glucose %5$@ %6$@" : { + "comment" : "VoiceOver summary of the day's insulin timeline chart. (1: weekday and date; 2: bolus total, e.g. 12.5 U; 3: basal total; 4: carbs total, e.g. 140 g; 5: latest glucose value; 6: glucose unit, mmol/L or mg/dL)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مخطط الأنسولين، %1$@، Bolus %2$@، القاعدية %3$@، الكارب %4$@، آخر قراءة سكر %5$@ %6$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Časová osa inzulínu, %1$@, bolus %2$@, bazál %3$@, sacharidy %4$@, poslední glukóza %5$@ %6$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintidslinje, %1$@, bolus %2$@, basal %3$@, kulhydrater %4$@, seneste glukose %5$@ %6$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin-Zeitleiste, %1$@, Bolus %2$@, Basal %3$@, KH %4$@, letzter Blutzucker %5$@ %6$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cronología de insulina, %1$@, bolo %2$@, basal %3$@, carbohidratos %4$@, última glucosa %5$@ %6$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliinin aikajana, %1$@, bolus %2$@, basaali %3$@, hiilihydraatit %4$@, viimeisin glukoosi %5$@ %6$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chronologie de l'insuline, %1$@, bolus %2$@, basal %3$@, glucides %4$@, dernière glycémie %5$@ %6$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ציר זמן אינסולין, %1$@, Bolus %2$@, Basal %3$@, פחמימות %4$@, סוכר אחרון %5$@ %6$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulin idővonal, %1$@, bólus %2$@, basal %3$@, szénhidrát %4$@, legutóbbi glükóz %5$@ %6$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cronologia insulina, %1$@, bolo %2$@, basale %3$@, carboidrati %4$@, ultima glicemia %5$@ %6$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インスリンのタイムライン、%1$@、ボーラス %2$@、ベーサル %3$@、カーボ %4$@、最新の血糖値 %5$@ %6$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintidslinje, %1$@, bolus %2$@, basal %3$@, karbohydrater %4$@, siste blodsukker %5$@ %6$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinetijdlijn, %1$@, bolus %2$@, basaal %3$@, koolhydraten %4$@, laatste glucose %5$@ %6$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oś czasu insuliny, %1$@, bolus %2$@, baza %3$@, węglowodany %4$@, ostatnia glukoza %5$@ %6$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linha do tempo de insulina, %1$@, bolus %2$@, basal %3$@, carbs %4$@, última glicose %5$@ %6$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cronologie insulină, %1$@, bolus %2$@, bazală %3$@, carbohidrați %4$@, ultima glicemie %5$@ %6$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хронология инсулина, %1$@, болюс %2$@, базал %3$@, углеводы %4$@, последняя глюкоза %5$@ %6$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Časová os inzulínu, %1$@, bolus %2$@, bazál %3$@, sacharidy %4$@, posledná glykémia %5$@ %6$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintidslinje, %1$@, bolus %2$@, basal %3$@, kolhydrater %4$@, senaste glukos %5$@ %6$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin zaman çizelgesi, %1$@, bolus %2$@, bazal %3$@, karb %4$@, son KŞ %5$@ %6$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хронологія інсуліну, %1$@, болюс %2$@, базал %3$@, вуглеводи %4$@, остання глюкоза %5$@ %6$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dòng thời gian insulin, %1$@, Bolus %2$@, Basal %3$@, Carbs %4$@, đường huyết mới nhất %5$@ %6$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素时间线,%1$@,大剂量 %2$@,基础 %3$@,碳水 %4$@,最新血糖 %5$@ %6$@" + } + } + } + }, + "IOB" : { + "comment" : "Abbreviation for insulin on board (active insulin) Abbreviation for insulin on board; label of a tile on the Now screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IA" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB" + } + } + } + }, + "Limited" : { + "comment" : "Trends Loop status: time closed loop was on but Loop wasn't completing runs", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "محدود" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omezeno" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begrænset" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eingeschränkt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limitado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rajoitettu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limitée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מוגבל" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korlátozott" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limitato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "制限あり" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begrenset" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beperkt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ograniczona" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limitado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limitat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ограничен" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obmedzená" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begränsad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sınırlı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обмежено" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hạn chế" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "受限" + } + } + } + }, + "Loop status" : { + "comment" : "Trends card title: how long Loop spent in closed loop, limited or open loop", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حالة Loop" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stav Loop" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-status" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-Status" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado de Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopin tila" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "État de Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מצב Loop" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop állapota" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stato di Loop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopの状態" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-status" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopstatus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stan Loop" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status do Loop" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starea Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус Loop" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stav Loop" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopstatus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop durumu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стан Loop" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trạng thái Loop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop 状态" + } + } + } + }, + "Manual %1$@" : { + "comment" : "Basal and bolus: bolus the person gave. (1: units, e.g. 16.2 U)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يدوي %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ručně %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuaalinen %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ידני %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kézi %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuale %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "手動 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Handmatig %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ręcznie %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вручную %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ručne %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вручну %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thủ công %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动 %1$@" + } + } + } + }, + "Median" : { + "comment" : "Trends Daily pattern legend: the median glucose line", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الوسيط" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medián" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Median" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Median" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediana" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediaani" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Médiane" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "חציון" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medián" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediana" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "中央値" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Median" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediana" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediana" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediană" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Медиана" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medián" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Median" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medyan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Медіана" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trung vị" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "中位数" + } + } + } + }, + "Next period" : { + "comment" : "VoiceOver label for the Trends button that moves forward one period", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الفترة التالية" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Další období" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Næste periode" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nächster Zeitraum" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Período siguiente" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seuraava jakso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Période suivante" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התקופה הבאה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Következő időszak" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periodo successivo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "次の期間" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neste periode" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volgende periode" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Następny okres" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Próximo período" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perioada următoare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следующий период" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ďalšie obdobie" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nästa period" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonraki dönem" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Наступний період" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khoảng thời gian sau" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一时段" + } + } + } + }, + "No CGM data" : { + "comment" : "Trends Average tile detail when the period has no glucose readings", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد بيانات CGM" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žádná data CGM" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine CGM-Daten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos del CGM" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei CGM-dataa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתוני CGM" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs CGM-adat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato CGM" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGMデータなし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen CGM-gegevens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak danych CGM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem dados do CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fără date CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных CGM" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žiadne údaje CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga CGM-data" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM verisi yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немає даних CGM" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có dữ liệu CGM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无 CGM 数据" + } + } + } + }, + "No CGM data %1$@ – %2$@" : { + "comment" : "Chart note: a gap in glucose readings. (1: start time; 2: end time)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد بيانات CGM %1$@ – %2$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žádná data CGM %1$@ – %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data %1$@ – %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine CGM-Daten %1$@ – %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos del CGM %1$@ – %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei CGM-dataa %1$@ – %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée CGM %1$@ – %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתוני CGM %1$@ – %2$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs CGM-adat %1$@ – %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato CGM %1$@ – %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGMデータなし %1$@ – %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data %1$@ – %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen CGM-gegevens %1$@ – %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak danych CGM %1$@ – %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem dados do CGM %1$@ – %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fără date CGM %1$@ – %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных CGM %1$@ – %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žiadne údaje CGM %1$@ – %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga CGM-data %1$@ – %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM verisi yok %1$@ – %2$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немає даних CGM %1$@ – %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có dữ liệu CGM %1$@ – %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无 CGM 数据 %1$@ – %2$@" + } + } + } + }, + "No CGM data %1$@ – %2$@ and %3$@" : { + "comment" : "Chart note: the longest gap in glucose readings, plus the shorter ones. (1: start time; 2: end time; 3: number of shorter gaps, e.g. 2 shorter gaps)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد بيانات CGM %1$@ – %2$@ و %3$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žádná data CGM %1$@ – %2$@ a %3$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data %1$@ – %2$@ og %3$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine CGM-Daten %1$@ – %2$@ und %3$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos del CGM %1$@ – %2$@ y %3$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei CGM-dataa %1$@ – %2$@ ja %3$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée CGM %1$@ – %2$@ et %3$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתוני CGM %1$@ – %2$@ ו-%3$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs CGM-adat %1$@ – %2$@, és %3$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato CGM %1$@ – %2$@ e %3$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGMデータなし %1$@ – %2$@(%3$@)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data %1$@ – %2$@ og %3$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen CGM-gegevens %1$@ – %2$@ en %3$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak danych CGM %1$@ – %2$@ oraz %3$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem dados do CGM %1$@ – %2$@ e %3$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fără date CGM %1$@ – %2$@ și %3$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных CGM %1$@ – %2$@ и ещё %3$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žiadne údaje CGM %1$@ – %2$@ a %3$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga CGM-data %1$@ – %2$@ och %3$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM verisi yok %1$@ – %2$@ ve %3$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немає даних CGM %1$@ – %2$@ і ще %3$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có dữ liệu CGM %1$@ – %2$@ và %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无 CGM 数据 %1$@ – %2$@,%3$@" + } + } + } + }, + "No CGM data in this period" : { + "comment" : "Trends chart placeholder when the period has no glucose readings", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد بيانات CGM في هذه الفترة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žádná data CGM v tomto období" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data i denne periode" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine CGM-Daten in diesem Zeitraum" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos del CGM en este período" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei CGM-dataa tällä jaksolla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée CGM sur cette période" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתוני CGM בתקופה זו" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs CGM-adat ebben az időszakban" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato CGM in questo periodo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この期間のCGMデータなし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data i denne perioden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen CGM-gegevens in deze periode" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak danych CGM w tym okresie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem dados do CGM neste período" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fără date CGM în această perioadă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных CGM за этот период" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žiadne údaje CGM v tomto období" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga CGM-data under denna period" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu dönemde CGM verisi yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немає даних CGM за цей період" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có dữ liệu CGM trong khoảng thời gian này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时段无 CGM 数据" + } + } + } + }, + "No CGM data on this day" : { + "comment" : "Chart note: there are no glucose readings for the whole day", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد بيانات CGM في هذا اليوم" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žádná data CGM v tento den" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data denne dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine CGM-Daten an diesem Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos del CGM este día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei CGM-dataa tänä päivänä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée CGM ce jour-là" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתוני CGM ביום זה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs CGM-adat ezen a napon" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato CGM in questo giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この日のCGMデータなし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data denne dagen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen CGM-gegevens op deze dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak danych CGM tego dnia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem dados do CGM neste dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fără date CGM în această zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных CGM за этот день" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žiadne údaje CGM v tento deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga CGM-data denna dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu günde CGM verisi yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немає даних CGM за цей день" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có dữ liệu CGM trong ngày này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这一天无 CGM 数据" + } + } + } + }, + "No CGM data yet today" : { + "comment" : "Chart note: there are no glucose readings so far today", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد بيانات CGM اليوم حتى الآن" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dnes zatím žádná data CGM" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data endnu i dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute noch keine CGM-Daten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aún no hay datos del CGM hoy" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei vielä CGM-dataa tänään" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée CGM aujourd'hui pour l'instant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין עדיין נתוני CGM היום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ma még nincs CGM-adat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ancora nessun dato CGM oggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日はまだCGMデータなし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen CGM-data ennå i dag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vandaag nog geen CGM-gegevens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dziś jeszcze brak danych CGM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ainda sem dados do CGM hoje" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Încă nu există date CGM azi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сегодня пока нет данных CGM" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dnes zatiaľ žiadne údaje CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga CGM-data än i dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bugün henüz CGM verisi yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сьогодні ще немає даних CGM" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hôm nay chưa có dữ liệu CGM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天尚无 CGM 数据" + } + } + } + }, + "No insulin recorded in this period" : { + "comment" : "Trends Insulin per day chart placeholder when no insulin was delivered in the period", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم يُسجَّل أنسولين في هذه الفترة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "V tomto období nebyl zaznamenán žádný inzulín" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intet insulin registreret i denne periode" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Insulin in diesem Zeitraum erfasst" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se registró insulina en este período" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei kirjattua insuliinia tällä jaksolla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune insuline enregistrée sur cette période" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא נרשם אינסולין בתקופה זו" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs rögzített inzulin ebben az időszakban" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna insulina registrata in questo periodo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この期間のインスリン記録なし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen insulin registrert i denne perioden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen insuline vastgelegd in deze periode" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak zapisanej insuliny w tym okresie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma insulina registrada neste período" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicio insulină înregistrată în această perioadă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "За этот период инсулин не записан" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "V tomto období nebol zaznamenaný žiadny inzulín" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inget insulin registrerat under denna period" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu dönemde kaydedilmiş insülin yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "За цей період інсулін не записано" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có insulin được ghi lại trong khoảng thời gian này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时段内无胰岛素记录" + } + } + } + }, + "No loop runs recorded in this period" : { + "comment" : "Trends Loop status placeholder when there is no Loop run history for the period", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم تُسجَّل تشغيلات Loop في هذه الفترة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "V tomto období nebyly zaznamenány žádné běhy Loop" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen Loop-kørsler registreret i denne periode" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Loop-Durchläufe in diesem Zeitraum erfasst" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se registraron ejecuciones de Loop en este período" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei Loop-ajoja tällä jaksolla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune exécution de Loop enregistrée sur cette période" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא נרשמו הרצות Loop בתקופה זו" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs rögzített Loop-futás ebben az időszakban" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna esecuzione di Loop registrata in questo periodo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この期間のLoop実行記録なし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen Loop-kjøringer registrert i denne perioden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen loop-runs vastgelegd in deze periode" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak zapisanych cykli Loop w tym okresie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma execução do Loop registrada neste período" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicio rulare Loop înregistrată în această perioadă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "За этот период запуски Loop не записаны" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "V tomto období neboli zaznamenané žiadne behy Loop" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga loopkörningar registrerade under denna period" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu dönemde kaydedilmiş Loop çalışması yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "За цей період запуски Loop не записано" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có lần chạy Loop nào được ghi lại trong khoảng thời gian này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时段内无 Loop 运行记录" + } + } + } + }, + "Not enough days for a pattern: %1$@ of 5" : { + "comment" : "Trends Daily pattern footnote when fewer than 5 days have readings; the chart shows raw readings instead. (1: days with readings)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أيام غير كافية لنمط: %1$@ من 5" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Málo dní pro profil: %1$@ z 5" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke nok dage til et mønster: %1$@ af 5" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht genug Tage für ein Profil: %1$@ von 5" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay suficientes días para un patrón: %1$@ de 5" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liian vähän päiviä profiiliin: %1$@ / 5" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas assez de jours pour un profil : %1$@ sur 5" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין מספיק ימים לדפוס: %1$@ מתוך 5" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nincs elég nap a mintázathoz: %1$@ / 5" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giorni insufficienti per un andamento: %1$@ su 5" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "パターン作成には日数不足:%1$@ / 5日" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke nok dager for et mønster: %1$@ av 5" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet genoeg dagen voor een patroon: %1$@ van 5" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Za mało dni na profil: %1$@ z 5" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dias insuficientes para um padrão: %1$@ de 5" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prea puține zile pentru un tipar: %1$@ din 5" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мало дней для профиля: %1$@ из 5" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Málo dní pre profil: %1$@ z 5" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "För få dagar för ett mönster: %1$@ av 5" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Örüntü için yeterli gün yok: %1$@ / 5" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замало днів для профілю: %1$@ з 5" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không đủ ngày để tạo mẫu: %1$@ / 5" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "天数不足以生成模式:%1$@ / 5" + } + } + } + }, + "Now" : { + "comment" : "Title at the top of the Now screen, showing current glucose and Loop status Title of the Now tab in the redesigned interface, showing current glucose and Loop status", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الآن" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyní" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jetzt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ahora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maintenant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עכשיו" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Most" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nå" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teraz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acum" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сейчас" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teraz" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şimdi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зараз" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện tại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前" + } + } + } + }, + "Older days come from Apple Health, which doesn't record the basal schedule, and some boluses there don't say whether they were manual or automatic." : { + "comment" : "Trends Insulin per day footnote when the period reaches back past Loop's own records into Apple Health, so scheduled basal can't be shown, or some boluses there have no record of whether they were manual or automatic", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأيام الأقدم مأخوذة من Apple Health، الذي لا يسجّل جدول القاعدي، وبعض الجرعات هناك لا تذكر ما إذا كانت يدوية أم تلقائية." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starší dny pocházejí z Apple Health, který nezaznamenává plán bazálu, a u některých bolusů tam není uvedeno, zda byly ruční, nebo automatické." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ældre dage kommer fra Apple Health, som ikke registrerer basalplanen, og nogle boluser der angiver ikke, om de var manuelle eller automatiske." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ältere Tage stammen aus Apple Health. Dort ist das Basalprogramm nicht gespeichert, und bei manchen Boli fehlt die Angabe, ob sie manuell oder automatisch waren." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los días anteriores provienen de Apple Health, que no registra el programa basal, y algunos bolos allí no indican si fueron manuales o automáticos." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vanhemmat päivät tulevat Apple Healthista, joka ei tallenna basaaliohjelmaa, eikä osa sen boluksista kerro, olivatko ne manuaalisia vai automaattisia." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les jours plus anciens viennent d'Apple Santé, qui n'enregistre pas le programme basal, et certains bolus n'y indiquent pas s'ils étaient manuels ou automatiques." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ימים ישנים יותר מגיעים מ-Apple Health, שאינו שומר את תוכנית הבזאלי, וחלק מהבולוסים שם אינם מציינים אם היו ידניים או אוטומטיים." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A régebbi napok az Apple Egészségből származnak, amely nem rögzíti a bázisprogramot, és ott egyes bólusoknál nincs feltüntetve, hogy kézi vagy automatikus volt-e." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I giorni precedenti provengono da Apple Salute, che non registra il programma basale, e alcuni boli lì non indicano se erano manuali o automatici." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "それ以前の日はAppleヘルスケアのデータです。ベーサルのスケジュールは記録されず、一部のボーラスは手動か自動かが記録されていません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eldre dager kommer fra Apple Helse, som ikke registrerer basalplanen, og noen boluser der oppgir ikke om de var manuelle eller automatiske." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oudere dagen komen uit Apple Gezondheid, dat het basaalschema niet vastlegt, en bij sommige bolussen daar staat niet of ze handmatig of automatisch waren." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starsze dni pochodzą z Apple Zdrowie, które nie zapisuje harmonogramu bazy, a przy niektórych bolusach nie ma tam informacji, czy były ręczne, czy automatyczne." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os dias mais antigos vêm do app Saúde, que não registra a programação basal, e alguns bolus ali não informam se foram manuais ou automáticos." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zilele mai vechi provin din Apple Sănătate, care nu înregistrează programul bazal, iar unele bolusuri de acolo nu arată dacă au fost manuale sau automate." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Более ранние дни взяты из Apple Здоровье, где не хранится базальная программа, а у некоторых болюсов не указано, были ли они ручными или автоматическими." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Staršie dni pochádzajú z Apple Zdravie, ktoré nezaznamenáva plán bazálu, a pri niektorých bolusoch tam nie je uvedené, či boli manuálne, alebo automatické." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Äldre dagar kommer från Apple Hälsa, som inte registrerar basalschemat, och vissa bolusar där anger inte om de var manuella eller automatiska." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha eski günler Apple Sağlık'tan gelir; bazal program orada kaydedilmez ve bazı bolusların manuel mi otomatik mi olduğu belirtilmez." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Давніші дні взято з Apple Здоровʼя, де не зберігається базальна програма, а для деяких болюсів не вказано, чи були вони ручними чи автоматичними." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Các ngày cũ hơn lấy từ Apple Sức khỏe, nơi không ghi lại lịch basal, và một số liều bolus ở đó không cho biết là thủ công hay tự động." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "较早的日子来自 Apple“健康”,其中不记录基础率计划,部分大剂量也没有记录是手动还是自动。" + } + } + } + }, + "Open loop / manual" : { + "comment" : "Trends Loop status: time closed loop was switched off", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حلقة مفتوحة / يدوي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otevřená smyčka / ručně" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åbent Loop / manuel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Loop / manuell" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circuito abierto / manual" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avoin säätö / manuaalinen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boucle ouverte / manuel" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Loop / ידני" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyitott hurok / kézi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop aperto / manuale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オープンループ/手動" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åpen Loop / manuell" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open loop / handmatig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla otwarta / ręcznie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo aberto / manual" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buclă deschisă / manual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открытый цикл / вручную" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otvorená slučka / ručne" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öppen loop / manuell" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açık döngü / manuel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Відкритий цикл / вручну" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vòng lặp mở / thủ công" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开环 / 手动" + } + } + } + }, + "per day, weekly average" : { + "comment" : "Trends Insulin per day card subtitle when each bar is a week's average daily total", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يوميًا، متوسط أسبوعي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "za den, týdenní průměr" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "pr. dag, ugegennemsnit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "pro Tag, Wochendurchschnitt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "por día, promedio semanal" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "päivässä, viikkokeskiarvo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "par jour, moyenne hebdomadaire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ליום, ממוצע שבועי" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "naponta, heti átlag" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "al giorno, media settimanale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日あたり、週平均" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "per dag, ukesgjennomsnitt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "per dag, weekgemiddelde" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dziennie, średnia tygodniowa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "por dia, média semanal" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "pe zi, medie săptămânală" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в день, среднее за неделю" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "za deň, týždenný priemer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "per dag, veckomedel" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "günlük, haftalık ortalama" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "на день, середнє за тиждень" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mỗi ngày, trung bình theo tuần" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日,按周平均" + } + } + } + }, + "per day, weekly average · %1$@" : { + "comment" : "Trends Insulin per day card subtitle when each bar is a week's average daily total and only some days of the period are averaged. (1: days averaged, e.g. 4 of 90 days)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يوميًا، متوسط أسبوعي · %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "za den, týdenní průměr · %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "pr. dag, ugegennemsnit · %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "pro Tag, Wochendurchschnitt · %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "por día, promedio semanal · %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "päivässä, viikkokeskiarvo · %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "par jour, moyenne hebdomadaire · %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ליום, ממוצע שבועי · %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "naponta, heti átlag · %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "al giorno, media settimanale · %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日あたり、週平均 · %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "per dag, ukesgjennomsnitt · %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "per dag, weekgemiddelde · %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dziennie, średnia tygodniowa · %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "por dia, média semanal · %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "pe zi, medie săptămânală · %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в день, среднее за неделю · %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "za deň, týždenný priemer · %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "per dag, veckomedel · %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "günlük, haftalık ortalama · %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "на день, середнє за тиждень · %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mỗi ngày, trung bình theo tuần · %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日,按周平均 · %1$@" + } + } + } + }, + "Period" : { + "comment" : "Trends: VoiceOver label of the picker that chooses how many days the screen covers", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الفترة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Období" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periode" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitraum" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periodo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jakso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Période" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תקופה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Időszak" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periodo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "期間" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periode" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periode" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Okres" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Período" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perioadă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Период" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obdobie" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Period" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dönem" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Період" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khoảng thời gian" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时段" + } + } + } + }, + "Previous period" : { + "comment" : "VoiceOver label for the Trends button that moves back one period", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الفترة السابقة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Předchozí období" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forrige periode" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorheriger Zeitraum" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Período anterior" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edellinen jakso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Période précédente" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התקופה הקודמת" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Előző időszak" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periodo precedente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前の期間" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forrige periode" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorige periode" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poprzedni okres" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Período anterior" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perioada anterioară" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предыдущий период" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predchádzajúce obdobie" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Föregående period" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önceki dönem" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попередній період" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khoảng thời gian trước" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一时段" + } + } + } + }, + "Scheduled %1$@" : { + "comment" : "Basal and bolus: what the basal schedule delivers over the same time, under the basal row. (1: units, e.g. 18.0 U)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المجدول %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plánovaný %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlagt %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geplant %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programado %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohjelmoitu %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programmé %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מתוזמן %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ütemezett %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programmato %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スケジュール %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlagt %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gepland %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaplanowane %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programada %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programat %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запланировано %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plánovaný %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schemalagd %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlanan %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "За розкладом %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Theo lịch %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预设 %1$@" + } + } + } + }, + "SD %1$@ · CV %2$@%%" : { + "comment" : "Trends Average tile detail: standard deviation and coefficient of variation of glucose. (1: standard deviation in the glucose unit, 2: coefficient of variation as a percentage number)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@ %%" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · VK %2$@ %%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "DE %1$@ · CV %2$@%%" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@ %%" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ET %1$@ · CV %2$@ %%" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "DS %1$@ · CV %2$@%%" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@ %%" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "DP %1$@ · CV %2$@%%" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@ %%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %%%2$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SD %1$@ · CV %2$@%%" + } + } + } + }, + "Summary" : { + "comment" : "Trends card title: the summary statistics tiles", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الملخص" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Souhrn" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oversigt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Übersicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumen" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yhteenveto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Résumé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סיכום" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Összegzés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riepilogo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "概要" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oversikt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overzicht" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podsumowanie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezumat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сводка" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Súhrn" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sammanfattning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özet" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Підсумок" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tóm tắt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "摘要" + } + } + } + }, + "Suspended" : { + "comment" : "Chart callout value: insulin delivery was suspended at the touched time Shown in the Basal tile on the Now screen when insulin delivery is suspended", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "متوقف مؤقتًا" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pozastaveno" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspenderet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterbrochen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspendida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pysäytetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspendu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מושהה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Felfüggesztve" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sospesa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一時停止中" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stanset" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onderbroken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wstrzymano" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspensa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspendat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приостановлено" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pozastavené" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Askıya alındı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Призупинено" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã tạm ngưng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已暂停" + } + } + } + }, + "this day" : { + "comment" : "Trends Basal and bolus card subtitle when the period is one complete day", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هذا اليوم" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "tento den" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "denne dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dieser Tag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "este día" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tämä päivä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ce jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יום זה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "ez a nap" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "questo giorno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この日" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "denne dagen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "deze dag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ten dzień" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "este dia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "această zi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "этот день" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "tento deň" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "denna dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu gün" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "цей день" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ngày này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当天" + } + } + } + }, + "Time" : { + "comment" : "Carb entry row label: the exact time the food was eaten", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الوقت" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Čas" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidspunkt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uhrzeit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aika" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heure" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שעה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Időpont" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "時刻" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidspunkt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tijd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godzina" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ora" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Čas" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Час" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间" + } + } + } + }, + "Time in range" : { + "comment" : "Trends card title: share of glucose readings in each range", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الوقت ضمن النطاق" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Čas v rozsahu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid i området" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeit im Zielbereich" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo en rango" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aika tavoitealueella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temps dans la cible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זמן בטווח" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tartományban töltött idő" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo nell'intervallo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "範囲内時間" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid i målområdet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tijd binnen bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czas w zakresie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo no alvo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timp în interval" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время в диапазоне" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Čas v rozsahu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid inom målområdet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aralıkta geçen süre" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Час у діапазоні" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian trong khoảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标范围内时间" + } + } + } + }, + "Time in range, daily pattern, insulin per day and Loop status" : { + "comment" : "Trends settings descriptive text", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الوقت ضمن النطاق والنمط اليومي والإنسولين اليومي وحالة Loop" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Čas v rozmezí, denní průběh, inzulin za den a stav Loop" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid i område, døgnmønster, insulin pr. dag og Loop-status" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeit im Zielbereich, Tagesverlauf, Insulin pro Tag und Loop-Status" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo en rango, patrón diario, insulina por día y estado de Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aika tavoitealueella, päivittäinen kuvio, insuliini päivässä ja Loopin tila" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temps dans la cible, profil quotidien, insuline par jour et état de Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זמן בטווח, דפוס יומי, אינסולין ליום ומצב Loop" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Céltartományban töltött idő, napi mintázat, napi inzulin és Loop állapot" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo nell'intervallo, andamento giornaliero, insulina al giorno e stato di Loop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "範囲内時間、日内パターン、1日のインスリン、Loopの状態" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid i målområdet, døgnmønster, insulin per dag og Loop-status" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tijd in bereik, dagpatroon, insuline per dag en loopstatus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czas w zakresie, profil dobowy, insulina na dzień i stan Loop" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo no alvo, padrão diário, insulina por dia e status do Loop" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timp în interval, tipar zilnic, insulină pe zi și starea Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время в диапазоне, суточный профиль, инсулин в день и статус Loop" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Čas v rozmedzí, denný priebeh, inzulín za deň a stav Loop" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid inom målområdet, dygnsmönster, insulin per dag och Loopstatus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hedef aralıkta geçen süre, günlük örüntü, günlük insülin ve Loop durumu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Час у діапазоні, добовий профіль, інсулін на день і стан Loop" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian trong ngưỡng, mẫu hình hằng ngày, insulin mỗi ngày và trạng thái Loop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标范围内时间、每日模式、每日胰岛素和 Loop 状态" + } + } + } + }, + "today so far" : { + "comment" : "Trends Basal and bolus card subtitle when the figures are today's total so far", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اليوم حتى الآن" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "dnes zatím" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "i dag indtil nu" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "heute bisher" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hoy hasta ahora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tänään tähän mennessä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aujourd'hui jusqu'ici" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "היום עד כה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "ma eddig" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "oggi finora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日のこれまで" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "i dag så langt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "vandaag tot nu toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dziś do tej pory" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "hoje até agora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "azi până acum" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сегодня на данный момент" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "dnes zatiaľ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "hittills i dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bugün şu ana kadar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "сьогодні наразі" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hôm nay đến giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日至今" + } + } + } + }, + "Today so far" : { + "comment" : "Title of the card on the Now screen showing today's insulin and carbs", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اليوم حتى الآن" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dnes zatím" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dag indtil nu" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute bisher" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoy hasta ahora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tänään tähän mennessä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd'hui jusqu'ici" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "היום עד כה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ma eddig" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oggi finora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日のこれまで" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dag så langt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vandaag tot nu toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dziś do tej pory" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoje até agora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Azi până acum" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сегодня на данный момент" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dnes zatiaľ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hittills i dag" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bugün şu ana kadar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сьогодні наразі" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hôm nay đến giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日至今" + } + } + } + }, + "Today so far %1$@, usually %2$@ by %3$@." : { + "comment" : "Trends Insulin per day footnote: today's insulin so far against the median by this time of day over the last 14 days. (1: today's units, 2: usual units, 3: time of day)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اليوم حتى الآن %1$@، وعادةً %2$@ بحلول %3$@." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dnes zatím %1$@, obvykle %2$@ do %3$@." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dag indtil nu %1$@, normalt %2$@ kl. %3$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute bisher %1$@, üblicherweise %2$@ bis %3$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoy hasta ahora %1$@, normalmente %2$@ a las %3$@." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tänään tähän mennessä %1$@, yleensä %2$@ klo %3$@ mennessä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd'hui jusqu'ici %1$@, d'habitude %2$@ à %3$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "היום עד כה %1$@, בדרך כלל %2$@ עד %3$@." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ma eddig %1$@, általában %2$@ ilyenkorra (%3$@)." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oggi finora %1$@, di solito %2$@ entro le %3$@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日のこれまで%1$@、通常は%3$@までに%2$@。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dag så langt %1$@, vanligvis %2$@ innen %3$@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vandaag tot nu toe %1$@, meestal %2$@ om %3$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dziś do tej pory %1$@, zwykle %2$@ do %3$@." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoje até agora %1$@, normalmente %2$@ até %3$@." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Azi până acum %1$@, de obicei %2$@ până la %3$@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сегодня на данный момент %1$@, обычно %2$@ к %3$@." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dnes zatiaľ %1$@, zvyčajne %2$@ do %3$@." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hittills i dag %1$@, vanligtvis %2$@ vid %3$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bugün şu ana kadar %1$@, genelde %3$@ itibarıyla %2$@." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сьогодні наразі %1$@, зазвичай %2$@ до %3$@." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hôm nay đến giờ %1$@, thường %2$@ trước %3$@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日至今 %1$@,通常到 %3$@ 为 %2$@。" + } + } + } + }, + "Total" : { + "comment" : "Basal and bolus total row: basal plus bolus", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإجمالي" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Celkem" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I alt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yhteensä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סה״כ" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Összesen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "合計" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Razem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spolu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplam" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усього" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "总计" + } + } + } + }, + "Total %1$@" : { + "comment" : "VoiceOver: total insulin. (1: units)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإجمالي %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Celkem %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I alt %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamt %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yhteensä %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סה״כ %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Összesen %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totale %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "合計 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totaal %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Razem %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spolu %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplam %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усього %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "总计 %1$@" + } + } + } + }, + "Total so far" : { + "comment" : "Basal and bolus total row for today, which isn't over yet", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإجمالي حتى الآن" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Celkem zatím" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I alt indtil nu" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamt bisher" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total hasta ahora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yhteensä tähän mennessä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total jusqu'ici" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סה״כ עד כה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Összesen eddig" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totale finora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "これまでの合計" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt så langt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totaal tot nu toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Razem do tej pory" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total até agora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total până acum" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего на данный момент" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spolu zatiaľ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt hittills" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şu ana kadar toplam" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усього наразі" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng đến giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "至今总计" + } + } + } + }, + "Total so far %1$@" : { + "comment" : "VoiceOver: today's total insulin so far. (1: units)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإجمالي حتى الآن %1$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Celkem zatím %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I alt indtil nu %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamt bisher %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total hasta ahora %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yhteensä tähän mennessä %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total jusqu'ici %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סה״כ עד כה %1$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Összesen eddig %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totale finora %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "これまでの合計 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt så langt %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totaal tot nu toe %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Razem do tej pory %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total até agora %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total până acum %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего на данный момент %1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spolu zatiaľ %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt hittills %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şu ana kadar toplam %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усього наразі %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng đến giờ %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "至今总计 %1$@" + } + } + } + }, + "Trends" : { + "comment" : "Title of the Trends tab Title of the Trends tab in the redesigned interface", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاتجاهات" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendy" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tendenser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trends" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tendencias" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tendances" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מגמות" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendek" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tendenze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トレンド" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trender" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trends" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tendências" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tendințe" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тренды" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trender" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eğilimler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тренди" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xu hướng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "趋势" + } + } + } + }, + "unknown" : { + "comment" : "VoiceOver value when active insulin or carbs at the current time isn't known", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "غير معروف" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "neznámé" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "ukendt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "unbekannt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desconocido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tuntematon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "inconnu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ידוע" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "ismeretlen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "sconosciuto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "ukjent" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "onbekend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nieznane" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "desconhecido" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "necunoscut" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "неизвестно" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "neznáme" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "okänt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bilinmiyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "невідомо" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không rõ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + } + } + }, + "Usually %1$@ by %2$@" : { + "comment" : "Trends: the median insulin delivered by this time of day over the last 14 days. (1: units, e.g. 28.4 U, 2: time of day)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عادةً %1$@ بحلول %2$@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obvykle %1$@ do %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normalt %1$@ kl. %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üblicherweise %1$@ bis %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normalmente %1$@ a las %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yleensä %1$@ klo %2$@ mennessä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "D'habitude %1$@ à %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בדרך כלל %1$@ עד %2$@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Általában %1$@ ilyenkorra (%2$@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di solito %1$@ entro le %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通常は%2$@までに%1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vanligvis %1$@ innen %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meestal %1$@ om %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwykle %1$@ do %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normalmente %1$@ até %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "De obicei %1$@ până la %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обычно %1$@ к %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zvyčajne %1$@ do %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vanligtvis %1$@ vid %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genelde %2$@ itibarıyla %1$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зазвичай %1$@ до %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thường %1$@ trước %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通常到 %2$@ 为 %1$@" + } + } + } + }, + "Very high" : { + "comment" : "Time in range band: glucose above 250 mg/dL (13.9 mmol/L) Time-in-range band name: glucose above 250 mg/dL (13.9 mmol/L)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرتفع جدًا" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velmi vysoká" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meget høj" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sehr hoch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muy alta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erittäin korkea" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Très élevée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גבוה מאוד" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nagyon magas" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Molto alta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "非常に高い" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svært høy" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeer hoog" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bardzo wysoka" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muito alta" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foarte ridicată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очень высокая" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veľmi vysoká" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mycket högt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çok yüksek" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дуже висока" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rất cao" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "非常高" + } + } + } + }, + "Very low" : { + "comment" : "Time in range band: glucose below 54 mg/dL (3.0 mmol/L) Time-in-range band name: glucose below 54 mg/dL (3.0 mmol/L)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "منخفض جدًا" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velmi nízká" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meget lav" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sehr niedrig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muy baja" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erittäin matala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Très basse" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נמוך מאוד" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nagyon alacsony" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Molto bassa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "非常に低い" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svært lav" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeer laag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bardzo niska" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muito baixa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foarte scăzută" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очень низкая" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veľmi nízka" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mycket lågt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çok düşük" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дуже низька" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rất thấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "非常低" + } + } + } }, " - " : { @@ -2423,6 +17178,48 @@ "state" : "translated", "value" : "%1$@ U" } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ وحدة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 単位" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } } } }, @@ -9054,7 +23851,147 @@ "comment" : "title for when automation was unsuccessful" }, "Average" : { - "comment" : "Average glucose metric title" + "comment" : "Average glucose metric title", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المتوسط" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Průměr" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gennemsnit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durchschnitt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Promedio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keskiarvo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moyenne" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ממוצע" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Átlag" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "平均" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gjennomsnitt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemiddeld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Średnia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Média" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Среднее" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Priemer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medelvärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ortalama" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Середнє" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trung bình" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平均值" + } + } + } }, "Average Glucose" : { @@ -9797,6 +24734,24 @@ "state" : "translated", "value" : "大剂量" } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bólus" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс" + } } } }, @@ -11576,9 +26531,6 @@ }, "CAUTION - Investigational device. Limited by Federal (or United States) law to investigational use." : { - }, - "CGM Active" : { - "comment" : "Percent of time CGM data present metric title" }, "Change the pump battery immediately" : { "comment" : "The notification alert describing a low pump battery", @@ -14827,12 +29779,6 @@ } } }, - "Daily glucose pattern" : { - "comment" : "Accessibility label for the daily glucose pattern (AGP) chart" - }, - "Daily Glucose Pattern" : { - "comment" : "Daily glucose pattern (AGP) section header" - }, "Date" : { "comment" : "Date picker label", "localizations" : { @@ -17341,12 +32287,6 @@ }, "During this kind of hard exercise, your body may release hormones that raise glucose. This is more common in the morning before eating." : { - }, - "Each 1% time in range ≈ 15 minutes." : { - "comment" : "TIR helper" - }, - "Each 5% increase in range is clinically beneficial." : { - "comment" : "TIR suggestion" }, "Easy breath. Can carry on a conversation." : { @@ -20530,6 +35470,24 @@ "state" : "translated", "value" : "克" } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "г" + } } } }, @@ -20742,6 +35700,18 @@ "state" : "translated", "value" : "血糖" } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glükóz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Глюкоза" + } } } }, @@ -21049,9 +36019,6 @@ } } }, - "Glucose Metrics" : { - "comment" : "Glucose metrics section header" - }, "Glucose Momentum" : { "comment" : "Title of the prediction input effect for glucose momentum", "localizations" : { @@ -21181,29 +36148,151 @@ "comment" : "Apple Health settings descriptive text" }, "GMI" : { - "comment" : "Glucose Management Indicator metric title" + "comment" : "Glucose Management Indicator metric title", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GMI" + } + } + } }, "Go to Settings > Focus." : { "comment" : "Focus modes step 1" }, - "Goal: <%@" : { - "comment" : "Average glucose goal" - }, - "Goal: <1%" : { - "comment" : "TIR goal for very low" - }, - "Goal: <5%" : { - "comment" : "TIR goal for very high" - }, - "Goal: <7%" : { - "comment" : "GMI goal" - }, - "Goal: >70%" : { - "comment" : "CGM active goal\nTIR goal for target range" - }, - "Goal: ≤36%" : { - "comment" : "Glucose variability goal" - }, "grams" : { }, @@ -21279,7 +36368,147 @@ }, "High" : { - "comment" : "Time-in-range band label (181–250 mg/dL)" + "comment" : "Time-in-range band label (181–250 mg/dL)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرتفع" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vysoká" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Høj" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korkea" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Élevée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גבוה" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Magas" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "高値" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Høy" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoog" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysoka" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alta" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ridicată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высокая" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vysoká" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Högt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüksek" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Висока" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cao" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高" + } + } + } }, "High Glucose" : { "localizations" : { @@ -21947,9 +37176,6 @@ } } }, - "In Range" : { - "comment" : "Time-in-range band label (70–180 mg/dL)" - }, "Includes basal and automated boluses" : { }, @@ -22241,6 +37467,30 @@ "state" : "translated", "value" : "胰岛素" } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulin" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulín" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Інсулін" + } } } }, @@ -25868,7 +41118,147 @@ } }, "Low" : { - "comment" : "Time-in-range band label (54–69 mg/dL)" + "comment" : "Time-in-range band label (54–69 mg/dL)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "منخفض" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nízká" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lav" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niedrig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baja" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Matala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basse" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נמוך" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alacsony" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bassa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "低値" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lav" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laag" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niska" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baixa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scăzută" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкая" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nízka" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lågt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düşük" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низька" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "低" + } + } + } }, "Low Glucose" : { "comment" : "Title for bolus screen warning when glucose is below glucose warning limit.\nTitle for bolus screen warning when glucose is below suspend threshold, but a bolus is recommended", @@ -26393,9 +41783,6 @@ }, "Meal Timing" : { - }, - "Median (line) with 25–75% and 5–95% bands, by time of day." : { - "comment" : "AGP chart explanation" }, "Medium Intensity (Aerobic)" : { @@ -27807,9 +43194,6 @@ }, "No Delivery" : { - }, - "No glucose data for this period." : { - "comment" : "Statistics empty state" }, "No Maximum Bolus Configured" : { "comment" : "Alert title for a missing maximum bolus setting error", @@ -28506,9 +43890,6 @@ }, "None in last 24 hours" : { - }, - "Not enough data to plot a profile." : { - "comment" : "AGP empty state" }, "Not Set" : { "comment" : "Health sharing status: not determined" @@ -33281,6 +48662,18 @@ "state" : "translated", "value" : "重试" } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Újra" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спробувати ще раз" + } } } }, @@ -33626,6 +49019,66 @@ "state" : "translated", "value" : "预设" } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المجدول" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plánovaný" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohjelmoitu" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ütemezett" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スケジュール" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programada" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plánovaný" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schemalagd" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "За розкладом" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Theo lịch" + } } } }, @@ -34144,9 +49597,6 @@ }, "She can do this by raising her **Overall Insulin** setting. This tells %@ to deliver more than her usual amount, making her insulin settings stronger." : { - }, - "Showing %1$d days of data — not enough for the full %2$d-day period yet." : { - "comment" : "Notice when the selected statistics range has incomplete data" }, "Shows last loop error" : { "comment" : "Loop Completion HUD accessibility hint", @@ -35195,9 +50645,6 @@ }, "Starting your exercise with high active insulin" : { - }, - "Statistics" : { - "comment" : "Statistics screen title\nStatistics settings button text" }, "Stay hydrated" : { @@ -37409,9 +52856,6 @@ }, "These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you." : { - }, - "This device keeps %d days of glucose. Increase the cache duration and rebuild to review the full period." : { - "comment" : "Notice when the local cache limits the statistics history" }, "This dose still has active insulin. Deleting it may cause Loop to make up for the reduced active insulin by dosing more." : { "comment" : "Warning when deleting a Loop-recorded dose that still has active insulin" @@ -38219,6 +53663,24 @@ "state" : "translated", "value" : "U" } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } } } }, @@ -40058,9 +55520,6 @@ } } }, - "Variability" : { - "comment" : "Coefficient of variation metric title" - }, "Version" : { "comment" : "Label for the app version row in the Support section" }, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 47f1b67d7a..662e8ac732 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -619,6 +619,8 @@ class LoopAppManager: NSObject { return SetupHistory(settings: settings, experiments: experiments) } viewModel.outcomesPackageBuilder = criticalEventLogExportManager.map { OutcomesPackageBuilder(logs: $0.logs) } + viewModel.historyProvider = LoopHistoryProvider(deviceManager: deviceDataManager, loopDataManager: loopDataManager, + settingsManager: settingsManager, temporaryPresetsManager: temporaryPresetsManager) return viewModel }() diff --git a/Loop/Redesign/DayCharts.swift b/Loop/Redesign/DayCharts.swift new file mode 100644 index 0000000000..900af50953 --- /dev/null +++ b/Loop/Redesign/DayCharts.swift @@ -0,0 +1,453 @@ +// +// DayCharts.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import Charts +import UIKit + +private typealias P = LoopDesign.Palette + +// MARK: - Shared axis helpers + +enum DayAxis { + /// Midnight, 6 AM, noon and 6 PM inside `window`, by the clock, so a day when the clocks change still marks 6 AM. + static func quarterDayMarks(in window: DateInterval, calendar: Calendar = .current) -> [Date] { + var marks: [Date] = [] + var day = calendar.startOfDay(for: window.start) + while day <= window.end { + for hour in [0, 6, 12, 18] { + if let mark = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: day), window.contains(mark) { + marks.append(mark) + } + } + guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } + return marks + } + + static func hourLabel(_ date: Date) -> String { + date.formatted(.dateTime.hour()) + } + + /// Gaps of more than 30 minutes between readings, so a chart can say where data is missing. + static func gaps(in points: [GlucosePoint], window: DateInterval, until end: Date) -> [DateInterval] { + let limit: TimeInterval = 30 * 60 + var gaps: [DateInterval] = [] + var previous = window.start + for point in points.sorted(by: { $0.date < $1.date }) where window.contains(point.date) { + if point.date.timeIntervalSince(previous) > limit { + gaps.append(DateInterval(start: previous, end: point.date)) + } + previous = point.date + } + let last = min(end, window.end) + if last.timeIntervalSince(previous) > limit { + gaps.append(DateInterval(start: previous, end: last)) + } + return gaps + } + + static func gapMessage(_ gaps: [DateInterval], window: DateInterval, until end: Date) -> String? { + guard let longest = gaps.max(by: { $0.duration < $1.duration }) else { return nil } + // A gap over the whole period would read "0:00 – 0:00". + if gaps.count == 1, longest.start == window.start, longest.end == min(end, window.end) { + return window.contains(end) + ? NSLocalizedString("No CGM data yet today", comment: "Chart note: there are no glucose readings so far today") + : NSLocalizedString("No CGM data on this day", comment: "Chart note: there are no glucose readings for the whole day") + } + let start = LoopFormat.time(longest.start) + let end = LoopFormat.time(longest.end) + if gaps.count > 1 { + let others = String.localizedStringWithFormat(NSLocalizedString("%lld shorter gaps", comment: "Chart note: how many other, shorter gaps in glucose readings there were; follows the longest gap. (1: number of gaps)"), gaps.count - 1) + return String(format: NSLocalizedString("No CGM data %1$@ – %2$@ and %3$@", comment: "Chart note: the longest gap in glucose readings, plus the shorter ones. (1: start time; 2: end time; 3: number of shorter gaps, e.g. 2 shorter gaps)"), start, end, others) + } + return String(format: NSLocalizedString("No CGM data %1$@ – %2$@", comment: "Chart note: a gap in glucose readings. (1: start time; 2: end time)"), start, end) + } +} + +// MARK: - DayTimelineCard + +/// A day on one timeline, for a one-day period on Trends. +struct DayTimelineCard: View { + let title: String + let history: DayHistory + let unit: GlucoseUnitStyle + let targetBands: [TargetBand] + var now: Date = Date() + var height: CGFloat = 230 + + static var todayTitle: String { + NSLocalizedString("Today so far", comment: "Title of the card on the Now screen showing today's insulin and carbs") + } + + static let legend: [LegendItem] = [ + LegendItem(swatch: .solid(P.bolus), label: NSLocalizedString("Bolus", comment: "Chart legend: solid bars are manual boluses")), + LegendItem(swatch: .hatch(P.bolus), label: NSLocalizedString("Automatic", comment: "Chart legend: hatched bars are automatic boluses given by Loop")), + LegendItem(swatch: .basal, label: NSLocalizedString("Basal given", comment: "Chart legend: filled area is basal insulin actually delivered")), + LegendItem(swatch: .dash(P.basalScheduled), label: NSLocalizedString("Scheduled", comment: "Chart legend: dashed line is the scheduled basal rate")), + ] + + var body: some View { + LoopCard { + CardHeader(title: title) { + Text(verbatim: "\(LoopFormat.totalUnits(history.totalUnits)) · \(LoopFormat.grams(history.carbGrams))") + } + InsulinTimelineView(history: history, unit: unit, targetBands: targetBands, now: now, height: height) + ChartLegend(items: Self.legend) + } + } +} + +// MARK: - InsulinTimeline + +/// Carbs, glucose and insulin for one day on one shared time axis. +/// +/// The lanes are drawn in a single chart with stacked y ranges so they can't drift apart: insulin 0–30, +/// glucose 36–92 (the fixed 2–20 mmol/L scale), carb markers at 97. +struct InsulinTimelineView: View { + let history: DayHistory + let unit: GlucoseUnitStyle + let targetBands: [TargetBand] + var now: Date = Date() + var height: CGFloat = 260 + + @State private var selection: Date? + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) private var typeSize + + private static let insulinTop = 30.0 + private static let basalTop = 12.0 + private static let glucoseRange = 36.0...92.0 + private static let carbLane = 97.0 + + private var window: DateInterval { history.window } + + private func y(glucose mgdl: Double) -> Double { + let domain = unit.dayDomain + let value = min(max(unit.value(mgdl), domain.lowerBound), domain.upperBound) + let fraction = (value - domain.lowerBound) / (domain.upperBound - domain.lowerBound) + return Self.glucoseRange.lowerBound + fraction * (Self.glucoseRange.upperBound - Self.glucoseRange.lowerBound) + } + + private var maxBasal: Double { + max(0.5, (history.basal.map(\.rate) + history.scheduledBasal.map(\.rate)).max() ?? 0) + } + + private var maxBolus: Double { + max(1, history.boluses.map(\.units).max() ?? 0) + } + + private func y(basal rate: Double) -> Double { rate / maxBasal * Self.basalTop } + private func y(bolus units: Double) -> Double { max(1.5, units / maxBolus * (Self.insulinTop - 4)) } + + private var gaps: [DateInterval] { DayAxis.gaps(in: history.glucose, window: window, until: now) } + + /// Labels closer than this to the previous label are dropped so they don't overprint; the marks still draw. + private var labelSpacing: TimeInterval { window.duration / 7 } + + private func spacedIDs(_ items: [T], date: (T) -> Date, id: (T) -> String) -> Set { + var kept = Set() + var last: Date? + for item in items.sorted(by: { date($0) < date($1) }) { + if let last, date(item).timeIntervalSince(last) < labelSpacing { continue } + kept.insert(id(item)) + last = date(item) + } + return kept + } + + /// One label per run of carb entries closer together than `labelSpacing`, giving the run's total. + /// It sits after the run's last marker, so no later marker in the run draws over it. + private var carbLabels: [String: Double] { + var labels: [String: Double] = [:] + var runStart: Date? + var runLast: (id: String, total: Double)? + for carb in history.carbs.sorted(by: { $0.date < $1.date }) { + if let start = runStart, let last = runLast, carb.date.timeIntervalSince(start) < labelSpacing { + runLast = (carb.id, last.total + carb.grams) + } else { + if let last = runLast { labels[last.id] = last.total } + runStart = carb.date + runLast = (carb.id, carb.grams) + } + } + if let last = runLast { labels[last.id] = last.total } + return labels + } + private var labelledBoluses: Set { spacedIDs(history.boluses.filter { !$0.automatic }, date: \.date, id: \.id) } + + var body: some View { + VStack(alignment: .leading, spacing: LoopDesign.Space.s2) { + chart + .frame(height: height) + if let message = DayAxis.gapMessage(gaps, window: window, until: now), history.source == .loop || !history.glucose.isEmpty { + Text(message) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(LoopDesign.Palette.inkMuted) + } + } + } + + private var chart: some View { + Chart { + // Quarter-day rules + ForEach(DayAxis.quarterDayMarks(in: window), id: \.self) { mark in + RuleMark(x: .value("Time", mark)) + .foregroundStyle(P.grid) + .lineStyle(StrokeStyle(lineWidth: 1)) + } + + // Glucose lane + ForEach(Array(targetBands.enumerated()), id: \.offset) { _, band in + RectangleMark(xStart: .value("Start", band.start), xEnd: .value("End", band.end), + yStart: .value("Low", y(glucose: band.range.lowerBound)), yEnd: .value("High", y(glucose: band.range.upperBound))) + .foregroundStyle(P.targetBand) + } + ForEach(Array(history.glucose.enumerated()), id: \.offset) { _, point in + PointMark(x: .value("Time", point.date), y: .value("Glucose", y(glucose: point.mgdl))) + .symbolSize(10) + .foregroundStyle(P.glucose) + } + + // Carb markers + let carbLabels = carbLabels + ForEach(history.carbs) { carb in + PointMark(x: .value("Time", carb.date), y: .value("Carbs", Self.carbLane)) + .symbolSize(60) + .foregroundStyle(P.carbs) + .annotation(position: .trailing, spacing: 2) { + if let grams = carbLabels[carb.id] { + Text(LoopFormat.grams(grams)) + .font(LoopDesign.Typeface.caption) + .foregroundStyle(P.ink) + } + } + } + + // Delivered basal, edged, then the schedule dashed over it + // Draw each recorded segment independently; a gap must not become an interpolated delivery area. + ForEach(Array(history.basal.enumerated()), id: \.offset) { _, segment in + RectangleMark(xStart: .value("Start", segment.start), xEnd: .value("End", segment.end), + yStart: .value("Base", 0), yEnd: .value("Basal", y(basal: segment.rate))) + .foregroundStyle(P.basal) + RuleMark(xStart: .value("Start", segment.start), xEnd: .value("End", segment.end), y: .value("Basal", y(basal: segment.rate))) + .foregroundStyle(P.basalEdge) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + ForEach(Array(history.scheduledBasal.enumerated()), id: \.offset) { _, value in + RuleMark(xStart: .value("Start", value.start), xEnd: .value("End", value.end), y: .value("Scheduled", y(basal: value.rate))) + .foregroundStyle(P.basalScheduled) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + + // Boluses over basal: solid for manual with a label, hatched for automatic. + // Each sits in a card-coloured slot a point wider, so it never touches the basal behind it. + ForEach(history.boluses) { bolus in + RectangleMark(x: .value("Time", bolus.date), yStart: .value("Base", 0), yEnd: .value("Bolus", y(bolus: bolus.units)), width: .fixed(7)) + .foregroundStyle(P.surfaceRaised) + } + let bolusLabels = labelledBoluses + ForEach(history.boluses) { bolus in + if bolus.automatic { + RectangleMark(x: .value("Time", bolus.date), yStart: .value("Base", 0), yEnd: .value("Bolus", y(bolus: bolus.units)), width: .fixed(5)) + .foregroundStyle(Hatch.paint(P.bolus, scheme: colorScheme)) + } else { + RectangleMark(x: .value("Time", bolus.date), yStart: .value("Base", 0), yEnd: .value("Bolus", y(bolus: bolus.units)), width: .fixed(5)) + .foregroundStyle(P.bolus) + .annotation(position: .top, spacing: 2) { + if bolusLabels.contains(bolus.id) { + Text(LoopFormat.number(bolus.units, decimals: 1)) + .font(LoopDesign.Typeface.caption) + .foregroundStyle(P.ink) + } + } + } + } + + // Now + if window.contains(now) { + RuleMark(x: .value("Now", now)) + .foregroundStyle(P.inkMuted) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) + } + + if let selection { + RuleMark(x: .value("Selected", selection)) + .foregroundStyle(P.ink.opacity(0.6)) + .lineStyle(StrokeStyle(lineWidth: 1)) + } + } + .chartXScale(domain: window.start...window.end) + .chartYScale(domain: 0...104) + .chartXAxis { + AxisMarks(values: DayAxis.quarterDayMarks(in: window).filter { + !typeSize.isAccessibilitySize || Calendar.current.component(.hour, from: $0).isMultiple(of: 12) + }) { value in + AxisValueLabel { + if let date = value.as(Date.self) { + Text(DayAxis.hourLabel(date)) + } + } + .font(LoopDesign.Typeface.caption) + .foregroundStyle(P.inkMuted) + } + } + .chartYAxis { + AxisMarks(position: .leading, values: unit.dayTicks.map { y(glucose: unit.mgdl($0)) }) { value in + AxisGridLine().foregroundStyle(P.grid) + AxisValueLabel { + if let mapped = value.as(Double.self), let tick = unit.dayTicks.first(where: { abs(y(glucose: unit.mgdl($0)) - mapped) < 0.01 }) { + Text(tick.formatted()) + } + } + .font(LoopDesign.Typeface.caption) + .foregroundStyle(P.inkMuted) + } + } + .chartOverlay { proxy in + ScrubOverlay(proxy: proxy, selection: $selection, clampTo: window) + } + .overlay(alignment: .top) { + if let selection { + ScrubCallout(rows: scrubRows(at: selection)) + .padding(.top, 2) + .allowsHitTesting(false) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilitySummary) + } + + private func scrubRows(at date: Date) -> [ScrubCallout.Row] { + var rows: [ScrubCallout.Row] = [.init(label: nil, value: LoopFormat.time(date), color: P.ink)] + if let glucose = nearest(history.glucose.map { ($0.date, $0.mgdl) }, to: date, within: 10 * 60) { + rows.append(.init(label: NSLocalizedString("Glucose", comment: "Chart callout label for the glucose reading at the touched time"), value: "\(unit.format(glucose)) \(unit.unitName)", color: P.ink)) + } + if let segment = LoopHistoryMath.basalRate(at: date, in: history.basal) { + let value = segment.kind == .suspended + ? NSLocalizedString("Suspended", comment: "Chart callout value: insulin delivery was suspended at the touched time") + : LoopFormat.rate(segment.rate) + rows.append(.init(label: NSLocalizedString("Basal", comment: "Chart callout label for the basal rate at the touched time"), value: value, color: P.ink)) + } + if let bolus = history.boluses.min(by: { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) }), + abs(bolus.date.timeIntervalSince(date)) < 5 * 60 { + rows.append(.init(label: bolus.automatic + ? NSLocalizedString("Automatic bolus", comment: "Chart callout label for a bolus Loop delivered automatically near the touched time") + : NSLocalizedString("Bolus", comment: "Chart callout label for a bolus near the touched time"), + value: LoopFormat.doseUnits(bolus.units), color: P.ink)) + } + return rows + } + + private var accessibilitySummary: String { + let day = LoopFormat.weekdayDay(window.start) + let bolus = LoopFormat.totalUnits(history.bolusUnits) + let basal = LoopFormat.totalUnits(history.basalUnits) + let carbs = LoopFormat.grams(history.carbGrams) + if let last = history.glucose.last { + return String(format: NSLocalizedString("Insulin timeline, %1$@, bolus %2$@, basal %3$@, carbs %4$@, latest glucose %5$@ %6$@", comment: "VoiceOver summary of the day's insulin timeline chart. (1: weekday and date; 2: bolus total, e.g. 12.5 U; 3: basal total; 4: carbs total, e.g. 140 g; 5: latest glucose value; 6: glucose unit, mmol/L or mg/dL)"), day, bolus, basal, carbs, unit.format(last.mgdl), unit.unitName) + } + return String(format: NSLocalizedString("Insulin timeline, %1$@, bolus %2$@, basal %3$@, carbs %4$@", comment: "VoiceOver summary of the day's insulin timeline chart with no glucose readings. (1: weekday and date; 2: bolus total, e.g. 12.5 U; 3: basal total; 4: carbs total, e.g. 140 g)"), day, bolus, basal, carbs) + } +} + +func nearest(_ values: [(Date, Double)], to date: Date, within limit: TimeInterval) -> Double? { + guard let best = values.min(by: { abs($0.0.timeIntervalSince(date)) < abs($1.0.timeIntervalSince(date)) }), + abs(best.0.timeIntervalSince(date)) <= limit else { + return nil + } + return best.1 +} + +// MARK: - Scrubbing + +/// Touch and hold then drag to scrub; a plain swipe still scrolls the page. +/// +/// A UIKit long-press recogniser is used because a SwiftUI long-press-then-drag gesture inside a table cell +/// takes every drag that starts on the chart, so the page couldn't scroll past it. +struct ScrubOverlay: View { + let proxy: ChartProxy + @Binding var selection: Date? + let clampTo: DateInterval + + var body: some View { + GeometryReader { geometry in + if #available(iOS 18.0, *) { + Color.clear + .contentShape(Rectangle()) + .gesture(ScrubPressRecognizer { location in + update(location, in: geometry) + } onEnd: { + selection = nil + }) + } else { + Color.clear + } + } + } + + private func update(_ location: CGPoint, in geometry: GeometryProxy) { + guard let frame = proxy.plotFrame else { return } + let origin = geometry[frame].origin + if let date: Date = proxy.value(atX: location.x - origin.x) { + selection = min(max(date, clampTo.start), clampTo.end) + } + } +} + +@available(iOS 18.0, *) +private struct ScrubPressRecognizer: UIGestureRecognizerRepresentable { + let onChange: (CGPoint) -> Void + let onEnd: () -> Void + + func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { + let recognizer = UILongPressGestureRecognizer() + recognizer.minimumPressDuration = 0.3 + recognizer.allowableMovement = 12 + return recognizer + } + + func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { + switch recognizer.state { + case .began, .changed: + onChange(context.converter.localLocation) + case .ended, .cancelled, .failed: + onEnd() + default: + break + } + } +} + +struct ScrubCallout: View { + struct Row: Hashable { + let label: String? + let value: String + let color: Color + } + + let rows: [Row] + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(rows, id: \.self) { row in + HStack(spacing: LoopDesign.Space.s1) { + if let label = row.label { + Text(label).foregroundStyle(P.inkMuted) + } + Text(row.value).foregroundStyle(row.color).fontWeight(.semibold) + } + } + } + .font(LoopDesign.Typeface.footnote.monospacedDigit()) + .padding(.horizontal, LoopDesign.Space.s2) + .padding(.vertical, LoopDesign.Space.s1) + .background(P.surfaceRaised, in: RoundedRectangle(cornerRadius: LoopDesign.Radius.sm)) + .overlay(RoundedRectangle(cornerRadius: LoopDesign.Radius.sm).strokeBorder(P.separator)) + } +} diff --git a/Loop/Redesign/LoopDesign.swift b/Loop/Redesign/LoopDesign.swift new file mode 100644 index 0000000000..931563a6b4 --- /dev/null +++ b/Loop/Redesign/LoopDesign.swift @@ -0,0 +1,306 @@ +// +// LoopDesign.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import UIKit + +/// Colours, spacing and type for the Trends screen. +/// +/// Every colour but one is one Loop already has: the `glucose`, `insulin`, `carbs`, `fresh` and `warning` assets, so a +/// DerivedAssetsOverride still recolours them, and the iOS system colours for grounds and text. Bands and fills are +/// those colours at reduced opacity. The one addition is `bolus`, a darker insulin for light mode. Text is `label` or `secondaryLabel`; colour is for marks, not for numbers. +/// Red is only for something that needs action now. +enum LoopDesign { + + enum Palette { + // Grounds + static let surface = Color(uiColor: .systemGroupedBackground) + static let surfaceRaised = Color(uiColor: .secondarySystemGroupedBackground) + /// A fill rather than a solid grey, so tiles still show on a card in a sheet, where dark mode's raised card is + /// the same grey as `systemGray5`. + static let surfaceSunken = Color(uiColor: .secondarySystemFill) + static let separator = Color(uiColor: .opaqueSeparator) + /// One step lighter than the card in dark mode, for the same reason. + static let grid = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? .systemGray4 : .systemGray5 }) + + // Text + static let ink = Color(uiColor: .label) + static let inkMuted = Color(uiColor: .secondaryLabel) + + // Quantities + static let glucose = Color("glucose") + static let targetBand = glucose.opacity(0.14) + static let agpOuter = glucose.opacity(0.16) + static let agpInner = glucose.opacity(0.34) + static let insulin = Color("insulin") + /// Basal is insulin's colour faded, with a stronger edge along its top. Insulin's own orange is too light for + /// a bolus on it in light mode, where it is under 2:1 against faded basal, so bolus has its own asset: a deep + /// orange in light mode, insulin's light orange in dark. Charts also keep a gap of the card's colour wherever + /// basal and bolus meet. + static let bolus = Color("bolus") + static let basal = insulin.opacity(0.25) + static let basalEdge = insulin.opacity(0.6) + static let basalScheduled = inkMuted + static let carbs = Color("carbs") + + // Loop state + static let statusAutomated = Color("fresh") + static let statusWarning = Color("warning") + } + + enum Space { + static let s1: CGFloat = 4 + static let s2: CGFloat = 8 + static let s3: CGFloat = 12 + static let s4: CGFloat = 16 + static let s6: CGFloat = 24 + static let s8: CGFloat = 32 + } + + enum Radius { + static let sm: CGFloat = 6 + static let md: CGFloat = 10 + static let lg: CGFloat = 14 + } + + /// Text styles follow Dynamic Type; the large numbers are fixed sizes in SF Pro Rounded with tabular figures. + enum Typeface { + static let statValue = Font.system(.title, design: .rounded).weight(.semibold).monospacedDigit() + static let statValueSmall = Font.system(.title3, design: .rounded).weight(.semibold).monospacedDigit() + static let title = Font.title.bold() + static let headline = Font.headline + static let body = Font.body + static let subhead = Font.subheadline + static let footnote = Font.footnote + static let caption = Font.caption2.weight(.medium).monospacedDigit() + } +} + +// MARK: - Card + +struct LoopCard: View { + var padding: CGFloat = LoopDesign.Space.s4 + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: LoopDesign.Space.s3) { + content + } + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LoopDesign.Palette.surfaceRaised, in: RoundedRectangle(cornerRadius: LoopDesign.Radius.lg, style: .continuous)) + } +} + +/// A card title with an optional trailing detail or action, as on every card in the mock-ups. +struct CardHeader: View { + let title: String + @ViewBuilder var trailing: Trailing + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + AdaptiveRow(spacing: typeSize.isAccessibilitySize ? LoopDesign.Space.s1 : LoopDesign.Space.s2) { + Text(title) + .font(LoopDesign.Typeface.headline) + .foregroundStyle(LoopDesign.Palette.ink) + .accessibilityAddTraits(.isHeader) + if !typeSize.isAccessibilitySize { + Spacer(minLength: LoopDesign.Space.s2) + } + trailing + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(LoopDesign.Palette.inkMuted) + } + } +} + +extension CardHeader where Trailing == EmptyView { + init(title: String) { + self.init(title: title) { EmptyView() } + } +} + +// MARK: - Legend + +struct LegendItem: Identifiable { + enum Swatch { + case solid(Color) + case outline(Color) + case dash(Color) + case dot(Color) + case hatch(Color) + case basal + } + + let id = UUID() + let swatch: Swatch + let label: String +} + +struct ChartLegend: View { + let items: [LegendItem] + + var body: some View { + FlowLayout(spacing: LoopDesign.Space.s3, lineSpacing: LoopDesign.Space.s1) { + ForEach(items) { item in + HStack(spacing: LoopDesign.Space.s1) { + LegendSwatch(swatch: item.swatch) + Text(item.label) + } + .accessibilityElement(children: .combine) + } + } + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(LoopDesign.Palette.inkMuted) + } +} + +/// The 10-point key for one kind of mark, shared by legends and the basal and bolus rows. +struct LegendSwatch: View { + let swatch: LegendItem.Swatch + @Environment(\.colorScheme) private var scheme + + var body: some View { + switch swatch { + case .solid(let color): + RoundedRectangle(cornerRadius: 2).fill(color).frame(width: 10, height: 10) + case .outline(let color): + RoundedRectangle(cornerRadius: 2).strokeBorder(color, lineWidth: 1.5).frame(width: 10, height: 10) + case .dash(let color): + Path { path in + path.move(to: CGPoint(x: 0, y: 1)) + path.addLine(to: CGPoint(x: 14, y: 1)) + } + .stroke(color, style: StrokeStyle(lineWidth: 2, dash: [4, 2])) + .frame(width: 14, height: 2) + case .dot(let color): + Circle().fill(color).frame(width: 8, height: 8) + case .hatch(let color): + RoundedRectangle(cornerRadius: 2).fill(Hatch.paint(color, scheme: scheme)).frame(width: 10, height: 10) + case .basal: + VStack(spacing: 0) { + Rectangle().fill(LoopDesign.Palette.basalEdge).frame(height: 2) + Rectangle().fill(LoopDesign.Palette.basal) + } + .frame(width: 10, height: 10) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + } +} + +/// Diagonal stripes over a pale tint of a colour: automatic bolus, wherever a mark is wide enough to show it. +enum Hatch { + static func paint(_ color: Color, scheme: ColorScheme) -> ImagePaint { + let resolved = UIColor(color).resolvedColor(with: UITraitCollection(userInterfaceStyle: scheme == .dark ? .dark : .light)) + let size: CGFloat = 5 + let image = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { context in + resolved.withAlphaComponent(scheme == .dark ? 0.3 : 0.22).setFill() + context.fill(CGRect(x: 0, y: 0, width: size, height: size)) + resolved.setStroke() + // One stripe corner to corner, plus the ends of its neighbours, so tiles join into unbroken lines. + let path = UIBezierPath() + path.lineWidth = 1.4 + for offset in [-size, 0, size] { + path.move(to: CGPoint(x: offset, y: size)) + path.addLine(to: CGPoint(x: offset + size, y: 0)) + } + path.stroke() + } + return ImagePaint(image: Image(uiImage: image)) + } +} + +/// Wraps its children onto new lines, for legends and chips. +struct FlowLayout: Layout { + var spacing: CGFloat + var lineSpacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? .infinity + var x: CGFloat = 0, y: CGFloat = 0, lineHeight: CGFloat = 0, maxX: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > 0 && x + size.width > width { + x = 0 + y += lineHeight + lineSpacing + lineHeight = 0 + } + x += size.width + spacing + maxX = max(maxX, x - spacing) + lineHeight = max(lineHeight, size.height) + } + return CGSize(width: maxX, height: y + lineHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX, y = bounds.minY, lineHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > bounds.minX && x + size.width > bounds.maxX { + x = bounds.minX + y += lineHeight + lineSpacing + lineHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + } +} + +/// A row that stacks vertically at accessibility text sizes, so tiles and buttons keep their numbers on one line. +struct AdaptiveRow: View { + @Environment(\.dynamicTypeSize) private var typeSize + var spacing: CGFloat = LoopDesign.Space.s2 + @ViewBuilder var content: Content + + var body: some View { + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: spacing)) + : AnyLayout(HStackLayout(spacing: spacing)) + layout { content } + } +} + +/// The round previous and next buttons beside a date or period. +struct StepButton: View { + let systemName: String + let label: String + var enabled = true + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.footnote.weight(.semibold)) + .frame(width: 30, height: 30) + .background(LoopDesign.Palette.surfaceRaised, in: Circle()) + .overlay(Circle().strokeBorder(LoopDesign.Palette.separator)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(enabled ? LoopDesign.Palette.ink : LoopDesign.Palette.inkMuted.opacity(0.5)) + .disabled(!enabled) + .accessibilityLabel(label) + } +} + +/// A message in place of a chart that has nothing to draw, saying why. +struct ChartPlaceholder: View { + let message: String + var height: CGFloat = 120 + + var body: some View { + Text(message) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(LoopDesign.Palette.inkMuted) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, minHeight: height) + .background(LoopDesign.Palette.surfaceSunken.opacity(0.5), in: RoundedRectangle(cornerRadius: LoopDesign.Radius.md)) + } +} diff --git a/Loop/Redesign/LoopHistoryMath.swift b/Loop/Redesign/LoopHistoryMath.swift new file mode 100644 index 0000000000..40a3e49bee --- /dev/null +++ b/Loop/Redesign/LoopHistoryMath.swift @@ -0,0 +1,428 @@ +// +// LoopHistoryMath.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Pure calculations behind the Trends screen. Nothing here touches a store. +enum LoopHistoryMath { + + // MARK: Glucose statistics + + struct GlucoseStats: Equatable { + let count: Int + let meanMgdl: Double + let sdMgdl: Double + /// Coefficient of variation, 0–1. + let cv: Double + /// Glucose management indicator, as a percentage (Bergenstal 2018). + let gmiPercent: Double + var gmiMmolMol: Double { (gmiPercent - 2.152) * 10.929 } + } + + static func glucoseStats(_ points: [GlucosePoint]) -> GlucoseStats? { + guard points.count >= 2 else { return nil } + let values = points.map(\.mgdl) + let mean = values.reduce(0, +) / Double(values.count) + let variance = values.reduce(0) { $0 + pow($1 - mean, 2) } / Double(values.count - 1) + let sd = variance.squareRoot() + return GlucoseStats(count: values.count, meanMgdl: mean, sdMgdl: sd, cv: sd / mean, gmiPercent: 3.31 + 0.02392 * mean) + } + + /// The share of the period with CGM readings, assuming one reading every 5 minutes. + static func cgmActiveFraction(_ points: [GlucosePoint], over interval: DateInterval) -> Double { + let expected = interval.duration / 300 + guard expected > 0 else { return 0 } + return min(1, Double(points.count) / expected) + } + + /// Whether GMI is as representative as the consensus asks: a period of at least 14 calendar days (today counts, + /// so "14 d" does while it runs), with CGM data for at least 70% of it. + static func gmiIsRepresentative(periodDays: Int, cgmActive: Double) -> Bool { + periodDays >= 14 && cgmActive >= 0.7 + } + + /// Hours a day with CGM readings: what a band's share of readings is a share of. One day gives that day's hours + /// with readings, so far when it is today; a longer period gives the average over its days. + static func cgmHoursPerDay(cgmActive: Double, over interval: DateInterval, periodDays: Int) -> Double { + periodDays <= 1 ? cgmActive * interval.duration / 3600 : cgmActive * 24 + } + + // MARK: Time in range + + enum RangeBand: Int, CaseIterable, Identifiable { + case veryHigh, high, inRange, low, veryLow + + var id: Int { rawValue } + + static func band(for mgdl: Double) -> RangeBand { + switch mgdl { + case ..<54: return .veryLow + case ..<70: return .low + case ...180: return .inRange + case ...250: return .high + default: return .veryHigh + } + } + } + + /// Fractions (0–1) per band, in ladder order. Empty input gives all zeros. + static func timeInRange(_ points: [GlucosePoint]) -> [RangeBand: Double] { + var counts: [RangeBand: Int] = [:] + for point in points { + counts[RangeBand.band(for: point.mgdl), default: 0] += 1 + } + let total = Double(points.count) + return Dictionary(uniqueKeysWithValues: RangeBand.allCases.map { band in + (band, total > 0 ? Double(counts[band] ?? 0) / total : 0) + }) + } + + /// Whole-number percentages that add up to 100 (largest remainder), so the labels never sum to 99 or 101. + static func roundedPercentages(_ fractions: [RangeBand: Double]) -> [RangeBand: Int] { + let raw = RangeBand.allCases.map { (band: $0, value: (fractions[$0] ?? 0) * 100) } + let total = raw.reduce(0) { $0 + $1.value } + guard total > 0 else { return Dictionary(uniqueKeysWithValues: RangeBand.allCases.map { ($0, 0) }) } + var floors = raw.map { (band: $0.band, floor: Int($0.value.rounded(.down)), remainder: $0.value - $0.value.rounded(.down)) } + var missing = Int(total.rounded()) - floors.reduce(0) { $0 + $1.floor } + for index in floors.indices.sorted(by: { floors[$0].remainder > floors[$1].remainder }) where missing > 0 { + floors[index].floor += 1 + missing -= 1 + } + return Dictionary(uniqueKeysWithValues: floors.map { ($0.band, $0.floor) }) + } + + // MARK: AGP + + struct AGPSlot: Equatable { + /// Minutes after midnight at the start of the slot. + let minute: Int + let p5: Double + let p25: Double + let p50: Double + let p75: Double + let p95: Double + } + + /// Readings folded onto one day in `slotMinutes` slots, as mg/dL percentiles. Slots with fewer than 3 readings are skipped. + static func agp(_ points: [GlucosePoint], slotMinutes: Int = 15, calendar: Calendar = .current) -> [AGPSlot] { + var slots: [Int: [Double]] = [:] + for point in points { + let parts = calendar.dateComponents([.hour, .minute], from: point.date) + let minute = (parts.hour ?? 0) * 60 + (parts.minute ?? 0) + slots[minute / slotMinutes, default: []].append(point.mgdl) + } + return slots.keys.sorted().compactMap { key in + let values = slots[key]!.sorted() + guard values.count >= 3 else { return nil } + return AGPSlot(minute: key * slotMinutes, + p5: percentile(values, 0.05), p25: percentile(values, 0.25), p50: percentile(values, 0.5), + p75: percentile(values, 0.75), p95: percentile(values, 0.95)) + } + } + + /// Linear-interpolated percentile of sorted values. + static func percentile(_ sorted: [Double], _ p: Double) -> Double { + guard !sorted.isEmpty else { return .nan } + let position = p * Double(sorted.count - 1) + let lower = Int(position.rounded(.down)), upper = Int(position.rounded(.up)) + let weight = position - Double(lower) + return sorted[lower] * (1 - weight) + sorted[upper] * weight + } + + /// The number of distinct calendar days with readings. + static func daysWithReadings(_ points: [GlucosePoint], calendar: Calendar = .current) -> Int { + Set(points.map { calendar.startOfDay(for: $0.date) }).count + } + + /// Whole days from the first reading to the end of `interval`, when that falls short of the period by more than a + /// day; nil when the readings span the period, or there are none. + static func cgmCoveredDays(_ points: [GlucosePoint], in interval: DateInterval, periodDays: Int) -> Int? { + guard periodDays > 1, let first = points.map(\.date).min() else { return nil } + let available = interval.end.timeIntervalSince(first) / 86400 + guard available + 1 < Double(periodDays) else { return nil } + return max(1, Int(available.rounded())) + } + + /// Units the schedule would deliver over `span`, or nil where the points don't cover all of it. + static func scheduledUnits(_ points: [ScheduledBasalPoint], over span: DateInterval) -> Double? { + var units = 0.0 + var covered: TimeInterval = 0 + for point in points { + let overlap = min(point.end, span.end).timeIntervalSince(max(point.start, span.start)) + guard overlap > 0 else { continue } + units += point.rate * overlap / 3600 + covered += overlap + } + return covered >= span.duration - 60 ? units : nil + } + + // MARK: Loop's stores and Apple Health + + /// Where one kind of data switches from Apple Health to Loop's own records: the later of the start of Loop's + /// local history and its first record of that kind. With no records of that kind, it is `now`, so all of it + /// comes from Health. Loop writes what it records to Health too, so reading Health only before this point and + /// Loop's stores only from it counts nothing twice. + static func healthBoundary(localHistoryStart: Date, firstLocalRecord: Date?, now: Date) -> Date { + max(localHistoryStart, firstLocalRecord ?? now) + } + + /// The earliest record that lies in `chunk`, from records given as start and end. A store can return records + /// outside the range it was asked for, such as a pump's dose still in progress, so only those overlapping the + /// chunk count. A record crossing into the chunk keeps its real start; one with no duration, such as a bolus, + /// counts by its start. + static func firstRecord(in chunk: DateInterval, of records: [(start: Date, end: Date)]) -> Date? { + records + .filter { $0.start < chunk.end && ($0.end > chunk.start || ($0.end <= $0.start && $0.start >= chunk.start)) } + .map(\.start) + .min() + } + + /// `window` cut at `boundary`: the part before it is read from Apple Health, the part from it on from Loop's + /// stores. Either is nil when the window has none of it; the two never overlap. + static func split(_ window: DateInterval, at boundary: Date) -> (health: DateInterval?, local: DateInterval?) { + let health = window.start < boundary ? DateInterval(start: window.start, end: min(window.end, boundary)) : nil + let local = window.end > boundary ? DateInterval(start: max(window.start, boundary), end: window.end) : nil + return (health, local) + } + + // MARK: Days + + /// Midnight-to-midnight intervals covering `interval`, oldest first, the last one cut off at `interval.end`. + static func days(in interval: DateInterval, calendar: Calendar = .current) -> [DateInterval] { + var result: [DateInterval] = [] + var start = calendar.startOfDay(for: interval.start) + while start < interval.end { + guard let next = calendar.date(byAdding: .day, value: 1, to: start) else { break } + result.append(DateInterval(start: max(start, interval.start), end: min(next, interval.end))) + start = next + } + return result + } + + struct DayTotal: Equatable, Identifiable { + let day: Date + let split: InsulinSplit + /// False for a day still in progress, so its partial total doesn't pull per-day averages down. + var isComplete = true + var id: Date { day } + var basal: Double { split.basal } + var bolus: Double { split.bolus } + var total: Double { split.total } + + init(day: Date, split: InsulinSplit, isComplete: Bool = true) { + self.day = day + self.split = split + self.isComplete = isComplete + } + + /// A total with no manual or automatic detail: all of its bolus counts as manual. + init(day: Date, basal: Double, bolus: Double, isComplete: Bool = true) { + self.init(day: day, split: InsulinSplit(basal: basal, manualBolus: bolus), isComplete: isComplete) + } + } + + /// The days per-day insulin figures average over: days with insulin, leaving out a day still in progress + /// unless no complete day has any. + static func averagingDays(_ totals: [DayTotal]) -> [DayTotal] { + let recorded = totals.filter { $0.total > 0 } + let complete = recorded.filter(\.isComplete) + return complete.isEmpty ? recorded : complete + } + + /// One source for the summary, legend and chart rule, before any weekly aggregation. + static func averageDailyInsulin(_ totals: [DayTotal]) -> Double { + let days = averagingDays(totals) + guard !days.isEmpty else { return 0 } + return days.reduce(0) { $0 + $1.total } / Double(days.count) + } + + /// Averages daily totals by calendar week (Monday start), for periods past 30 days. + static func weekly(_ totals: [DayTotal], calendar: Calendar = .current) -> [DayTotal] { + var cal = calendar + cal.firstWeekday = 2 + let groups = Dictionary(grouping: totals) { cal.dateInterval(of: .weekOfYear, for: $0.day)?.start ?? $0.day } + return groups.keys.sorted().map { week in + DayTotal(day: week, split: InsulinSplit.average(averagingDays(groups[week]!).map(\.split))) + } + } + + // MARK: Basal + + struct ScheduleValue { + let start: Date + let end: Date + let rate: Double + } + + /// A basal record from the pump: scheduled delivery, a temp basal or a suspend. + struct BasalDelivery { + let start: Date + let end: Date + /// U/h; 0 for a suspend. + let rate: Double + let kind: BasalSegment.Kind + let automatic: Bool + } + + /// Delivered basal over `window` from the pump's records (the latest-starting one wins where they overlap). + /// + /// From `recordsStart` on, time without a basal record counts at `programmedRates`, since that is what the pump + /// delivers on its own; this matches Loop's own insulin totals (`InsulinDeliveryHistory`). Before it, or without + /// it, gaps stay unknown. + static func basalSegments(window: DateInterval, schedule: [ScheduleValue], deliveries temps: [BasalDelivery], + programmedRates: [ScheduleValue] = [], recordsStart: Date? = nil) -> [BasalSegment] { + var cuts = Set([window.start, window.end]) + if let recordsStart, window.contains(recordsStart) { cuts.insert(recordsStart) } + for value in schedule + programmedRates { + if window.contains(value.start) { cuts.insert(value.start) } + if window.contains(value.end) { cuts.insert(value.end) } + } + for temp in temps { + if window.contains(temp.start) { cuts.insert(temp.start) } + if window.contains(temp.end) { cuts.insert(temp.end) } + } + let sorted = cuts.sorted() + let sortedTemps = temps.sorted { $0.start < $1.start } + // The furthest end among records up to each index bounds the backward search, so a week of five-minute + // records stays linear rather than scanning every record for every cut. + var furthestEnd: [Date] = [] + furthestEnd.reserveCapacity(sortedTemps.count) + for temp in sortedTemps { furthestEnd.append(max(furthestEnd.last ?? temp.end, temp.end)) } + func latestRecord(covering mid: Date) -> BasalDelivery? { + var low = 0, high = sortedTemps.count + while low < high { + let middle = (low + high) / 2 + if sortedTemps[middle].start <= mid { low = middle + 1 } else { high = middle } + } + var index = low - 1 + while index >= 0, furthestEnd[index] > mid { + if mid < sortedTemps[index].end { return sortedTemps[index] } + index -= 1 + } + return nil + } + var segments: [BasalSegment] = [] + for (a, b) in zip(sorted, sorted.dropFirst()) where b > a { + let mid = a.addingTimeInterval(b.timeIntervalSince(a) / 2) + let scheduled = schedule.first { $0.start <= mid && mid < $0.end }?.rate + let segment: BasalSegment + if let temp = latestRecord(covering: mid) { + segment = BasalSegment(start: a, end: b, rate: temp.kind == .suspended ? 0 : temp.rate, scheduledRate: scheduled, + kind: temp.kind, automatic: temp.automatic) + } else if let recordsStart, a >= recordsStart, + let programmed = programmedRates.first(where: { $0.start <= mid && mid < $0.end })?.rate { + segment = BasalSegment(start: a, end: b, rate: programmed, scheduledRate: scheduled, kind: .scheduled, automatic: false) + } else { + continue + } + if let last = segments.last, last.end == segment.start, last.rate == segment.rate, last.kind == segment.kind, + last.scheduledRate == segment.scheduledRate, last.automatic == segment.automatic { + segments[segments.count - 1] = BasalSegment(start: last.start, end: segment.end, rate: last.rate, + scheduledRate: last.scheduledRate, kind: last.kind, automatic: last.automatic) + } else { + segments.append(segment) + } + } + return segments + } + + /// Basal rate at a moment, from delivered segments. + static func basalRate(at date: Date, in segments: [BasalSegment]) -> BasalSegment? { + segments.last { $0.start <= date && date < $0.end } + } + + // MARK: Loop modes + + struct LoopRun { + let date: Date + let succeeded: Bool + /// The run stopped because a manual temp basal was running, which Loop shows as open loop. + var manualTempBasal = false + } + + /// Closed loop switched on or off, from Loop's stored settings. + struct ModeChange: Equatable { + let date: Date + let closedLoop: Bool + } + + /// Splits `interval` into closed, limited and open stretches. + /// + /// Each 5-minute slot is open when closed loop was switched off (from `modeChanges`), or while a manual temp basal + /// from `manualTempBasals` runs. A run that stopped for a manual temp basal with no dose record of it holds the + /// loop open for 15 minutes; where the record exists, its end or cancellation decides instead. Otherwise a slot is + /// closed when Loop completed a run in the 15 minutes before it ends, and limited when it didn't. Before the first mode change the setting is unknown, so the + /// result starts at the later of `coverageStart` and the first mode change. + static func loopModeSpans(interval: DateInterval, runs: [LoopRun], modeChanges: [ModeChange], coverageStart: Date?, + manualTempBasals: [DateInterval] = [], + recency: TimeInterval = 15 * 60, slot: TimeInterval = 5 * 60) -> [LoopModeSpan] { + guard let coverageStart, let firstChange = modeChanges.map(\.date).min() else { return [] } + let start = max(interval.start, coverageStart, firstChange) + guard interval.end > start else { return [] } + + let sortedRuns = runs.sorted { $0.date < $1.date } + let changes = modeChanges.sorted { $0.date < $1.date } + var spans: [LoopModeSpan] = [] + var runIndex = 0 + var lastRun: LoopRun? = nil + var lastRunHasDoseRecord = false + var lastSuccess: Date? = nil + var changeIndex = 0 + var closedSetting: Bool? = nil + let manualTemps = manualTempBasals.sorted { $0.start < $1.start } + var manualIndex = 0 + let recorded = { (date: Date) in manualTemps.contains { $0.start <= date && date <= $0.end } } + + var t = start + while t < interval.end { + let slotEnd = min(t.addingTimeInterval(slot), interval.end) + while runIndex < sortedRuns.count && sortedRuns[runIndex].date <= slotEnd { + let run = sortedRuns[runIndex] + lastRun = run + lastRunHasDoseRecord = run.manualTempBasal && recorded(run.date) + if run.succeeded { + lastSuccess = run.date + } + runIndex += 1 + } + while changeIndex < changes.count && changes[changeIndex].date <= slotEnd { + closedSetting = changes[changeIndex].closedLoop + changeIndex += 1 + } + + // A manual temp basal holds Loop open for as long as the pump runs it, whatever a later run failed on. + let middle = t.addingTimeInterval(slotEnd.timeIntervalSince(t) / 2) + while manualIndex < manualTemps.count && manualTemps[manualIndex].end <= middle { + manualIndex += 1 + } + let inManualTemp = manualIndex < manualTemps.count && manualTemps[manualIndex].start <= middle + + let mode: LoopModeSpan.Mode + if closedSetting == false || inManualTemp { + mode = .open + } else if let lastRun, lastRun.manualTempBasal, !lastRunHasDoseRecord, slotEnd.timeIntervalSince(lastRun.date) <= recency { + mode = .open + } else if let lastSuccess, slotEnd.timeIntervalSince(lastSuccess) <= recency { + mode = .closed + } else { + mode = .limited + } + + if let last = spans.last, last.mode == mode, last.end == t { + spans[spans.count - 1] = LoopModeSpan(start: last.start, end: slotEnd, mode: mode) + } else { + spans.append(LoopModeSpan(start: t, end: slotEnd, mode: mode)) + } + t = slotEnd + } + return spans + } + + static func duration(of mode: LoopModeSpan.Mode, in spans: [LoopModeSpan]) -> TimeInterval { + spans.filter { $0.mode == mode }.reduce(0) { $0 + $1.duration } + } +} diff --git a/Loop/Redesign/LoopHistoryModels.swift b/Loop/Redesign/LoopHistoryModels.swift new file mode 100644 index 0000000000..f5aae9857d --- /dev/null +++ b/Loop/Redesign/LoopHistoryModels.swift @@ -0,0 +1,347 @@ +// +// LoopHistoryModels.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopAlgorithm + +/// Plain value types the redesigned screens draw from. Glucose is kept in mg/dL and converted only for display, +/// so the statistics don't depend on the display unit. + +struct GlucosePoint: Equatable { + let date: Date + let mgdl: Double + /// A fingerstick or other reading typed in: drawn on charts, but left out of CGM statistics. + var userEntered = false +} + +struct BolusEvent: Equatable, Identifiable { + let id: String + let date: Date + let units: Double + /// Issued by Loop (automatic bolus) rather than by the person. + let automatic: Bool + /// Logged by hand, such as an injection, rather than delivered by the pump. Always a manual dose. + let manuallyEntered: Bool + /// Kept so a manually entered dose can be deleted from the dose store. + var dose: DoseEntry? = nil + /// False when the record doesn't say whether it was automatic or given by the person, such as some doses in Apple Health. + var automaticKnown = true + + /// Whether a record says what kind of bolus it is: it carries the automatic flag, or it was logged by hand, which + /// makes it manual. + static func kindKnown(automatic: Bool?, manuallyEntered: Bool) -> Bool { + automatic != nil || manuallyEntered + } + + static func == (lhs: BolusEvent, rhs: BolusEvent) -> Bool { + lhs.id == rhs.id && lhs.date == rhs.date && lhs.units == rhs.units && lhs.automatic == rhs.automatic + } +} + +struct BasalSegment: Equatable { + enum Kind: Equatable { + /// The scheduled rate, with no temp basal running. + case scheduled + /// A temp basal, set by Loop or by hand. + case temp + case suspended + } + + let start: Date + let end: Date + /// The rate actually delivered, in U/h. + let rate: Double + /// The profile rate at the time, in U/h, if known. + let scheduledRate: Double? + let kind: Kind + let automatic: Bool + + var hours: Double { end.timeIntervalSince(start) / 3600 } + var units: Double { rate * hours } + /// Units delivered above (positive) or below (negative) the schedule. + var netUnits: Double { + guard let scheduledRate else { return 0 } + return (rate - scheduledRate) * hours + } + + func clamped(to interval: DateInterval) -> BasalSegment? { + let s = max(start, interval.start), e = min(end, interval.end) + guard e > s else { return nil } + return BasalSegment(start: s, end: e, rate: rate, scheduledRate: scheduledRate, kind: kind, automatic: automatic) + } +} + +struct ScheduledBasalPoint: Equatable { + let start: Date + let end: Date + let rate: Double +} + +/// The correction range over part of a chart, in mg/dL. +struct TargetBand: Equatable { + let start: Date + let end: Date + let range: ClosedRange +} + +struct CarbEvent: Equatable, Identifiable { + let id: String + let date: Date + let grams: Double + let absorptionHours: Double? + let foodType: String? + let createdByLoop: Bool + /// Kept so the entry can be edited or deleted. + var entry: StoredCarbEntry? = nil + + static func == (lhs: CarbEvent, rhs: CarbEvent) -> Bool { + lhs.id == rhs.id && lhs.date == rhs.date && lhs.grams == rhs.grams + } +} + +/// Everything known about one day or reporting period, for InsulinTimeline. +struct DayHistory { + var window: DateInterval + var glucose: [GlucosePoint] = [] + var boluses: [BolusEvent] = [] + var basal: [BasalSegment] = [] + var scheduledBasal: [ScheduledBasalPoint] = [] + var carbs: [CarbEvent] = [] + /// Where the data came from, so older days can say they are missing Loop's own detail. + var source: HistorySource = .loop + /// Where Loop's own dose records take over from Apple Health: before it, insulin has no schedule, and only the + /// boluses Loop wrote say whether they were automatic. + var localInsulinStart = Date.distantPast + + var bolusUnits: Double { boluses.reduce(0) { $0 + $1.units } } + var basalUnits: Double { basal.reduce(0) { $0 + $1.units } } + var carbGrams: Double { carbs.reduce(0) { $0 + $1.grams } } + var totalUnits: Double { bolusUnits + basalUnits } + var insulinSplit: InsulinSplit { InsulinSplit(basal: basalUnits, boluses: boluses) } + + /// Only what falls inside `interval`, with basal cut at its edges. + func clipped(to interval: DateInterval) -> DayHistory { + var history = self + let inside: (Date) -> Bool = { $0 >= interval.start && $0 < interval.end } + history.window = interval + history.glucose = glucose.filter { inside($0.date) } + history.boluses = boluses.filter { inside($0.date) } + history.basal = basal.compactMap { $0.clamped(to: interval) } + history.carbs = carbs.filter { inside($0.date) } + return history + } +} + +/// Basal and bolus for one day, or averaged per day, with bolus split into manual and automatic. +struct InsulinSplit: Equatable { + var basal = 0.0 + var manualBolus = 0.0 + var automaticBolus = 0.0 + /// Bolus that doesn't say whether it was manual or automatic; while there is any, the split isn't shown. + var unclassifiedBolus = 0.0 + + var bolus: Double { manualBolus + automaticBolus + unclassifiedBolus } + var total: Double { basal + bolus } + var isClassified: Bool { unclassifiedBolus < 0.005 } + + /// Shares in whole percent that add up to 100. + var bolusPercent: Int { total > 0 ? Int((bolus / total * 100).rounded()) : 0 } + var basalPercent: Int { total > 0 ? 100 - bolusPercent : 0 } +} + +extension InsulinSplit { + init(basal: Double, boluses: [BolusEvent]) { + self.basal = basal + for bolus in boluses { + if !bolus.automaticKnown { + unclassifiedBolus += bolus.units + } else if bolus.automatic { + automaticBolus += bolus.units + } else { + manualBolus += bolus.units + } + } + } + + /// The per-day average of several days' splits. + static func average(_ splits: [InsulinSplit]) -> InsulinSplit { + guard !splits.isEmpty else { return InsulinSplit() } + let n = Double(splits.count) + return InsulinSplit(basal: splits.reduce(0) { $0 + $1.basal } / n, + manualBolus: splits.reduce(0) { $0 + $1.manualBolus } / n, + automaticBolus: splits.reduce(0) { $0 + $1.automaticBolus } / n, + unclassifiedBolus: splits.reduce(0) { $0 + $1.unclassifiedBolus } / n) + } +} + +enum HistorySource { + /// Loop's own stores: manual versus automatic, temp basals and the schedule are all known. + case loop + /// Apple Health, for days older than Loop keeps. There is no basal schedule, and only the boluses Loop wrote say + /// whether they were automatic. + case health +} + +/// One stretch of the period in a single loop mode. +struct LoopModeSpan: Equatable { + enum Mode: Equatable { + case closed + case limited + case open + } + + let start: Date + let end: Date + let mode: Mode + + var duration: TimeInterval { end.timeIntervalSince(start) } +} + +// MARK: - Glucose unit + +struct GlucoseUnitStyle: Equatable { + let isMmol: Bool + + init(unit: LoopUnit) { + isMmol = unit == .millimolesPerLiter + } + + init(isMmol: Bool) { + self.isMmol = isMmol + } + + static let mgdlPerMmol = 18.0182 + + var unitName: String { isMmol ? "mmol/L" : "mg/dL" } + + func value(_ mgdl: Double) -> Double { isMmol ? mgdl / Self.mgdlPerMmol : mgdl } + func mgdl(_ displayValue: Double) -> Double { isMmol ? displayValue * Self.mgdlPerMmol : displayValue } + + func format(_ mgdl: Double) -> String { + isMmol ? LoopFormat.number(mgdl / Self.mgdlPerMmol, decimals: 1) : LoopFormat.number(mgdl, decimals: 0) + } + + /// Fixed chart scales from the design: 2–20 mmol/L on day views, 0–22 on AGP. + var dayDomain: ClosedRange { isMmol ? 2...20 : 36...360 } + var agpDomain: ClosedRange { isMmol ? 0...22 : 0...400 } + var dayTicks: [Double] { isMmol ? [4, 10, 16] : [70, 180, 300] } + var agpTicks: [Double] { isMmol ? [0, 4, 10, 16, 22] : [0, 70, 180, 250, 400] } +} + +// MARK: - Formatting + +enum LoopFormat { + /// A number in the user's locale, e.g. "7.3" or "7,3", with no grouping so values read like Loop's own screens. + static func number(_ value: Double, decimals: Int) -> String { + value.formatted(.number.precision(.fractionLength(decimals)).grouping(.never)) + } + + static func doseUnits(_ units: Double) -> String { + String(format: NSLocalizedString("%1$@ U", comment: "Format string for an amount of insulin in units. (1: amount)"), number(units, decimals: 2)) + } + + static func totalUnits(_ units: Double) -> String { + String(format: NSLocalizedString("%1$@ U", comment: "Format string for an amount of insulin in units. (1: amount)"), number(units, decimals: 1)) + } + + static func rate(_ rate: Double) -> String { + String(format: NSLocalizedString("%1$@ U/h", comment: "Format string for an insulin rate in units per hour. (1: rate)"), number(rate, decimals: 2)) + } + + static func grams(_ grams: Double) -> String { + String(format: NSLocalizedString("%1$@ g", comment: "Format string for an amount of carbohydrate in grams. (1: grams)"), number(grams, decimals: 0)) + } + + static func signed(_ value: Double, decimals: Int = 2) -> String { + let s = number(abs(value), decimals: decimals) + if abs(value) < pow(10, -Double(decimals)) / 2 { return "+" + s } + return (value < 0 ? "−" : "+") + s + } + + static func time(_ date: Date) -> String { + date.formatted(date: .omitted, time: .shortened) + } + + /// "25 Oct 2026" + static func day(_ date: Date) -> String { + date.formatted(.dateTime.day().month(.abbreviated).year()) + } + + /// "Sun 25 Oct" + static func weekdayDay(_ date: Date) -> String { + date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)) + } + + /// "12 – 25 Oct 2026", "28 Sep – 25 Oct 2026", "20 Dec 2025 – 2 Jan 2026", in the user's locale. + static func range(_ start: Date, _ end: Date, calendar: Calendar = .current) -> String { + if calendar.isDate(start, inSameDayAs: end) { + return day(end) + } + let formatter = DateIntervalFormatter() + formatter.calendar = calendar + formatter.dateTemplate = "dMMMy" + return formatter.string(from: start, to: end) + } + + /// "13d 11h", "9h 45m", "40m" in English; abbreviated in the user's language. + static func duration(_ interval: TimeInterval) -> String { + let minutes = Int((interval / 60).rounded()) + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.allowedUnits = minutes >= 1440 ? [.day, .hour] : minutes >= 60 ? [.hour, .minute] : [.minute] + formatter.zeroFormattingBehavior = .dropAll + return formatter.string(from: TimeInterval(minutes * 60)) ?? "" + } +} + +// MARK: - Store conversion boundaries + +extension BolusEvent { + init?(dose: DoseEntry, in interval: DateInterval) { + guard dose.type == .bolus, dose.startDate >= interval.start, dose.startDate < interval.end else { return nil } + self.init(id: dose.syncIdentifier ?? "\(dose.startDate.timeIntervalSince1970)", date: dose.startDate, + units: dose.unitsInDeliverableIncrements, automatic: !dose.manuallyEntered && (dose.automatic ?? false), + manuallyEntered: dose.manuallyEntered, dose: dose, + // A dose imported without the automatic flag, or not yet reconciled, is of unknown kind; one logged + // by hand is manual. + automaticKnown: BolusEvent.kindKnown(automatic: dose.automatic, manuallyEntered: dose.manuallyEntered)) + } +} + +extension LoopHistoryMath.BasalDelivery { + init?(dose: DoseEntry) { + let kind: BasalSegment.Kind + switch dose.type { + case .basal: kind = .scheduled + case .tempBasal: kind = .temp + case .suspend: kind = .suspended + default: return nil + } + let hours = dose.endDate.timeIntervalSince(dose.startDate) / 3600 + guard hours > 0 else { return nil } + self.init(start: dose.startDate, end: dose.endDate, + rate: kind == .suspended ? 0 : dose.unitsInDeliverableIncrements / hours, + kind: kind, automatic: dose.automatic ?? (kind == .temp)) + } +} + +extension BasalSegment { + init?(healthSample sample: HKQuantitySample, in interval: DateInterval) { + let hours = sample.endDate.timeIntervalSince(sample.startDate) / 3600 + guard hours > 0 else { return nil } + let scheduled = (sample.metadata?["com.loopkit.InsulinKit.MetadataKeyScheduledBasalRate"] as? HKQuantity)? + .doubleValue(for: HKUnit.internationalUnit().unitDivided(by: .hour())) + let segment = BasalSegment(start: sample.startDate, end: sample.endDate, + rate: sample.quantity.doubleValue(for: .internationalUnit()) / hours, + scheduledRate: scheduled, kind: .temp, + automatic: (sample.metadata?["com.loopkit.InsulinKit.MetadataKeyAutomaticallyIssued"] as? NSNumber)?.boolValue ?? false) + guard let clamped = segment.clamped(to: interval) else { return nil } + self = clamped + } +} diff --git a/Loop/Redesign/LoopHistoryProvider.swift b/Loop/Redesign/LoopHistoryProvider.swift new file mode 100644 index 0000000000..ad9ec1021e --- /dev/null +++ b/Loop/Redesign/LoopHistoryProvider.swift @@ -0,0 +1,489 @@ +// +// LoopHistoryProvider.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore +import LoopAlgorithm +import os + +/// Reads glucose, insulin, carbs and loop runs for the redesigned screens. +/// +/// Loop keeps `LOOP_LOCAL_CACHE_DURATION_DAYS` (90 by default) days in its own stores. From the start of that window, +/// or from Loop's first record of a kind when that is later, everything comes from those stores, so manual and +/// automatic doses, temp basals and the schedule are known. Earlier days come from Apple Health, which has glucose, +/// carbs and insulin split into basal and bolus, and whether Loop gave a bolus automatically only for the doses Loop +/// wrote, but not the basal schedule. +final class LoopHistoryProvider { + + private let deviceManager: DeviceDataManager + private let loopDataManager: LoopDataManager + /// Read once on the main actor, which owns `SettingsManager`; the store itself is safe to use from anywhere. + private let settingsStore: SettingsStore? + private let temporaryPresetsManager: TemporaryPresetsManager + private let dosingDecisionStore: DosingDecisionStoreProtocol + private let doseStore: DoseStore + private let carbStore: CarbStore + private let glucoseStore: GlucoseStore + private let healthStore = HKHealthStore() + private let log = OSLog(category: "LoopHistoryProvider") + private let recordStartsCache = OSAllocatedUnfairLock(initialState: RecordStartsCache()) + private var storeObservers: [NSObjectProtocol] = [] + + @MainActor + init(deviceManager: DeviceDataManager, loopDataManager: LoopDataManager, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager) { + self.deviceManager = deviceManager + self.loopDataManager = loopDataManager + self.settingsStore = settingsManager.settingsStore + self.temporaryPresetsManager = temporaryPresetsManager + self.dosingDecisionStore = loopDataManager.dosingDecisionStore + self.doseStore = deviceManager.doseStore + self.carbStore = deviceManager.carbStore + self.glucoseStore = deviceManager.glucoseStore + + // A record added or edited earlier than a cached start would otherwise stay hidden until the cache expires, + // so any change to a store searches for that kind's start again. + let cache = recordStartsCache + func forget(_ name: Notification.Name, of store: AnyObject, _ clear: @escaping @Sendable (inout LocalRecordStarts) -> Void) -> NSObjectProtocol { + NotificationCenter.default.addObserver(forName: name, object: store, queue: nil) { _ in + cache.withLock { cached in + clear(&cached.starts) + cached.generation += 1 + } + } + } + storeObservers = [ + forget(GlucoseStore.glucoseSamplesDidChange, of: glucoseStore) { $0.glucose = nil }, + forget(DoseStore.valuesDidChange, of: doseStore) { $0.insulin = nil }, + forget(CarbStore.carbEntriesDidChange, of: carbStore) { $0.carbs = nil }, + ] + } + + deinit { + storeObservers.forEach(NotificationCenter.default.removeObserver) + } + + @MainActor + var unitStyle: GlucoseUnitStyle { + GlucoseUnitStyle(unit: deviceManager.displayGlucosePreference.unit) + } + + /// The start of what Loop keeps locally. + var localHistoryStart: Date { + Date().addingTimeInterval(-Bundle.main.localCacheDuration) + } + + /// How many days Loop keeps in its own stores (`LOOP_LOCAL_CACHE_DURATION_DAYS`). + var localHistoryDays: Int { + Int((Bundle.main.localCacheDuration / 86400).rounded()) + } + + @MainActor + var glucoseTargetRange: ClosedRange? { + guard let range = temporaryPresetsManager.effectiveCorrectionRange() else { return nil } + return range.lowerBound.doubleValue(for: .milligramsPerDeciliter)...range.upperBound.doubleValue(for: .milligramsPerDeciliter) + } + + /// The median insulin delivered from midnight to this time of day over the last 14 days. + func typicalInsulinByNow() async -> (typical: TypicalInsulinDelivery, at: Date)? { + guard let history = try? await loopDataManager.insulinDeliveryHistory(days: 14), + let typical = history.typicalDelivery(by: history.end) else { return nil } + return (typical, history.end) + } + + /// The targets that actually applied, including dated presets and changes to the stored profile. + func glucoseTargetBands(in interval: DateInterval) async throws -> [TargetBand] { + guard let settingsStore else { return [] } + return try await loopDataManager.glucoseTargetBands(in: interval, settingsStore: settingsStore) + } + + /// SettingsStore's schedule queries extend the first profile backwards to the requested start. Reporting must + /// instead leave time before the first saved profile, and settings records without a profile, unknown. + private static func settingsHistory(in interval: DateInterval, from store: SettingsStore) async throws -> [(settings: StoredSettings, interval: DateInterval)] { + var records = Array(try await store.getStoredSettings(start: interval.start, end: interval.end).reversed()) + if let previous = try await store.getStoredSettings(end: interval.start, limit: 1).first { + records.insert(previous, at: 0) + } + return records.enumerated().compactMap { index, settings in + let start = max(interval.start, settings.date) + let end = min(interval.end, index + 1 < records.count ? records[index + 1].date : interval.end) + guard end > start else { return nil } + return (settings, DateInterval(start: start, end: end)) + } + } + + static func basalHistory(in interval: DateInterval, from store: SettingsStore) async throws -> [AbsoluteScheduleValue] { + let history = try await settingsHistory(in: interval, from: store) + return history.flatMap { record in + record.settings.basalRateSchedule?.truncatingBetween(start: record.interval.start, end: record.interval.end) ?? [] + } + } + + static func targetHistory(in interval: DateInterval, from store: SettingsStore) async throws -> [TargetBand] { + let history = try await settingsHistory(in: interval, from: store) + return history.flatMap { record -> [TargetBand] in + guard let schedule = record.settings.glucoseTargetRangeSchedule else { return [] } + return schedule.truncatingBetween(start: record.interval.start, end: record.interval.end).map { + let range = $0.value.quantityRange(for: schedule.unit) + return TargetBand(start: $0.startDate, end: $0.endDate, + range: range.lowerBound.doubleValue(for: .milligramsPerDeciliter)...range.upperBound.doubleValue(for: .milligramsPerDeciliter)) + } + } + } + + // MARK: - Days + + /// Glucose, insulin and carbs in `window`. Each kind comes from Apple Health before Loop's own records of it + /// begin, and from Loop's stores after, so a Loop younger than its cache still shows what Health has for the + /// days before it. + func dayHistory(_ window: DateInterval) async throws -> DayHistory { + var history = DayHistory(window: window) + let starts = try await localRecordStarts() + let now = Date(), localStart = localHistoryStart + let boundary = { LoopHistoryMath.healthBoundary(localHistoryStart: localStart, firstLocalRecord: $0, now: now) } + let glucoseBoundary = boundary(starts.glucose), insulinBoundary = boundary(starts.insulin), carbBoundary = boundary(starts.carbs) + let glucose = LoopHistoryMath.split(window, at: glucoseBoundary) + let insulin = LoopHistoryMath.split(window, at: insulinBoundary) + let carbs = LoopHistoryMath.split(window, at: carbBoundary) + + async let localGlucose = localGlucose(glucose.local) + async let localInsulin = localInsulin(insulin.local, recordsStart: starts.insulin) + async let localCarbs = localCarbs(carbs.local) + async let healthGlucose = healthGlucose(glucose.health) + async let healthInsulin = healthInsulin(insulin.health) + async let healthCarbs = healthCarbs(carbs.health) + + let (localBoluses, localBasal, scheduled) = try await localInsulin + let (healthBoluses, healthBasal) = try await healthInsulin + history.glucose = try await healthGlucose + localGlucose + history.boluses = healthBoluses + localBoluses + history.basal = healthBasal + localBasal + history.scheduledBasal = scheduled + history.carbs = try await healthCarbs + localCarbs + history.localInsulinStart = insulinBoundary + if window.end <= min(glucoseBoundary, insulinBoundary, carbBoundary) { + history.source = .health + } + try Task.checkCancellation() + return history + } + + /// Where Loop's own records of each kind begin, inside its local history; nil for a kind it has none of. + struct LocalRecordStarts: Equatable { + var glucose: Date? + var insulin: Date? + var carbs: Date? + } + + private struct RecordStartsCache { + var fetched: Date? + var starts = LocalRecordStarts() + /// Counts store changes, so a search that overlapped one doesn't save what it found. + var generation = 0 + } + + /// A found start is kept for a few minutes, so each period doesn't search again, and forgotten as soon as that + /// kind's store changes. A kind with no records yet is searched every time, because its first record can arrive at + /// any moment. + func localRecordStarts() async throws -> LocalRecordStarts { + let now = Date(), start = localHistoryStart + let cached = recordStartsCache.withLock { $0 } + let fresh = cached.fetched.map { now.timeIntervalSince($0) < 300 } ?? false + var starts = fresh ? cached.starts : LocalRecordStarts() + let glucoseStore = self.glucoseStore, doseStore = self.doseStore + if starts.glucose == nil { + starts.glucose = try await Self.firstRecord(from: start, to: now) { chunk in + try await glucoseStore.getGlucoseSamples(start: chunk.start, end: chunk.end).filter { !$0.isDisplayOnly }.map(\.startDate) + } + } + if starts.insulin == nil { + starts.insulin = try await Self.firstRecord(from: start, to: now) { chunk in + let doses = try await doseStore.getNormalizedDoseEntries(start: chunk.start, end: chunk.end) + return LoopHistoryMath.firstRecord(in: chunk, of: doses.map { (start: $0.startDate, end: $0.endDate) }).map { [$0] } ?? [] + } + } + if starts.carbs == nil { + starts.carbs = try await carbStore.getCarbEntries(start: start, end: now, dateAscending: true, fetchLimit: 1).first?.startDate + } + let found = starts + recordStartsCache.withLock { latest in + guard latest.generation == cached.generation else { return } + latest.fetched = fresh ? cached.fetched : now + latest.starts = found + } + return found + } + + /// The earliest date `dates` gives from `start` to `end`, read a week at a time so that finding it doesn't load + /// all of the local history. + private static func firstRecord(from start: Date, to end: Date, _ dates: (DateInterval) async throws -> [Date]) async throws -> Date? { + var chunkStart = start + while chunkStart < end { + let chunk = DateInterval(start: chunkStart, end: min(end, chunkStart.addingTimeInterval(.days(7)))) + if let first = try await dates(chunk).min() { + return first + } + chunkStart = chunk.end + } + return nil + } + + // MARK: - Loop runs + + /// Loop runs in the local window, oldest first, with when the record of them begins. + func loopRuns() async throws -> (runs: [LoopHistoryMath.LoopRun], coverageStart: Date?) { + let start = localHistoryStart + var runs: [LoopHistoryMath.LoopRun] + if let store = dosingDecisionStore as? DosingDecisionStore { + // Decode only the fields a run needs, and only Loop's local window. + runs = try await store.getDosingDecisions(start: start, end: Date(), as: LoopRunRecord.self) + .filter { $0.reason == "loop" } + .map { Self.run(date: $0.date, issues: ($0.errors ?? []).map(\.id)) } + } else { + runs = try await anchoredLoopRuns() + } + try Task.checkCancellation() + runs = runs.filter { $0.date >= start }.sorted { $0.date < $1.date } + return (runs, runs.first?.date) + } + + private static func run(date: Date, issues: [String]) -> LoopHistoryMath.LoopRun { + LoopHistoryMath.LoopRun(date: date, succeeded: issues.isEmpty, manualTempBasal: issues.first == "manualTempBasalRunning") + } + + private struct LoopRunRecord: Decodable { + struct Issue: Decodable { let id: String } + let date: Date + let reason: String + let errors: [Issue]? + } + + /// Fetch each snapshot independently: overlapping reloads must not append the same anchored page twice. + private func anchoredLoopRuns() async throws -> [LoopHistoryMath.LoopRun] { + var anchor: DosingDecisionStore.QueryAnchor? + var runs: [LoopHistoryMath.LoopRun] = [] + while true { + let page: (DosingDecisionStore.QueryAnchor, [StoredDosingDecision]) = try await withCheckedThrowingContinuation { continuation in + dosingDecisionStore.executeDosingDecisionQuery(fromQueryAnchor: anchor, limit: 1000) { result in + switch result { + case .success(let anchor, let decisions): continuation.resume(returning: (anchor, decisions)) + case .failure(let error): continuation.resume(throwing: error) + } + } + } + try Task.checkCancellation() + anchor = page.0 + runs += page.1.filter { $0.reason == "loop" }.map { Self.run(date: $0.date, issues: $0.errors.map(\.id)) } + if page.1.count < 1000 { break } + } + return runs + } + + /// When closed loop was switched on or off, from the settings Loop stores on every change: the setting in force at + /// `interval.start`, then each change after it. Empty when Loop has no settings store. + func loopModeChanges(in interval: DateInterval) async throws -> [LoopHistoryMath.ModeChange] { + guard let store = settingsStore else { return [] } + var changes: [LoopHistoryMath.ModeChange] = [] + if let previous = try await store.getStoredSettings(end: interval.start, limit: 1).first { + changes.append(.init(date: interval.start, closedLoop: previous.dosingEnabled)) + } + // Newest first from the store; every settings save is a record, so keep only the ones that switch the mode. + for settings in try await store.getStoredSettings(start: interval.start, end: interval.end).reversed() + where changes.last?.closedLoop != settings.dosingEnabled { + changes.append(.init(date: settings.date, closedLoop: settings.dosingEnabled)) + } + return changes + } + + /// When manual temp basals ran in `interval`, from Loop's dose records, ended where the pump ended or cancelled + /// them. Loop shows the loop as open while one runs. + func manualTempBasals(in interval: DateInterval) async throws -> [DateInterval] { + // The last run before the period may have stopped for a temp basal that ended before the period too. + // Keep its record so the run-based fallback cannot reopen the loop after that recorded end. + try await doseStore.getNormalizedDoseEntries(start: interval.start.addingTimeInterval(-.minutes(15)), end: interval.end) + .filter { $0.type == .tempBasal && $0.automatic == false && $0.endDate > $0.startDate } + .map { DateInterval(start: $0.startDate, end: $0.endDate) } + } + + // MARK: - Loop's stores + + private func localGlucose(_ interval: DateInterval?) async throws -> [GlucosePoint] { + guard let interval else { return [] } + do { + let samples = try await glucoseStore.getGlucoseSamples(start: interval.start, end: interval.end) + return samples + .filter { !$0.isDisplayOnly } + .map { GlucosePoint(date: $0.startDate, mgdl: $0.quantity.doubleValue(for: .milligramsPerDeciliter), userEntered: $0.wasUserEntered) } + } catch { + os_log("Glucose fetch failed: %{public}@", log: log, type: .error, String(describing: error)) + throw error + } + } + + /// Doses, basal and the schedule for `interval`. `recordsStart` is Loop's earliest local dose: after it, + /// unrecorded time runs at the pump's programmed rate. + private func localInsulin(_ interval: DateInterval?, recordsStart: Date?) async throws -> ([BolusEvent], [BasalSegment], [ScheduledBasalPoint]) { + guard let interval else { return ([], [], []) } + let doses: [DoseEntry] + do { + doses = try await doseStore.getNormalizedDoseEntries(start: interval.start, end: interval.end) + } catch { + os_log("Dose fetch failed: %{public}@", log: log, type: .error, String(describing: error)) + throw error + } + + let boluses = doses.compactMap { BolusEvent(dose: $0, in: interval) } + + let basalHistory: [AbsoluteScheduleValue] + if let store = settingsStore { + basalHistory = try await Self.basalHistory(in: interval, from: store) + } else { + basalHistory = [] + } + let temporaryPresetsManager = self.temporaryPresetsManager + let overrides = await MainActor.run { + temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: interval.start, endDate: interval.end) + } + // A resolved daily schedule repeats recent presets on every day. Use the stored profile and dated overrides. + let scheduleValues = overrides.applyBasal(over: basalHistory) + let schedule = scheduleValues.map { LoopHistoryMath.ScheduleValue(start: $0.startDate, end: $0.endDate, rate: $0.value) } + let deliveries = doses.compactMap { LoopHistoryMath.BasalDelivery(dose: $0) } + let now = Date() + let window = DateInterval(start: interval.start, end: max(interval.start, min(interval.end, now))) + // As in Loop's insulin totals: after the first record, unrecorded time runs at the pump's programmed rate. + let programmed = basalHistory.map { LoopHistoryMath.ScheduleValue(start: $0.startDate, end: $0.endDate, rate: $0.value) } + let basal = window.duration > 0 + ? LoopHistoryMath.basalSegments(window: window, schedule: schedule, deliveries: deliveries, + programmedRates: programmed, recordsStart: recordsStart) + : [] + let scheduled = schedule.map { ScheduledBasalPoint(start: max($0.start, interval.start), end: min($0.end, interval.end), rate: $0.rate) } + return (boluses, basal, scheduled) + } + + private func localCarbs(_ interval: DateInterval?) async throws -> [CarbEvent] { + guard let interval else { return [] } + do { + let entries = try await carbStore.getCarbEntries(start: interval.start, end: interval.end) + return entries.map { entry in + CarbEvent(id: entry.syncIdentifier ?? "\(entry.startDate.timeIntervalSince1970)", + date: entry.startDate, + grams: entry.quantity.doubleValue(for: .gram), + absorptionHours: entry.absorptionTime.map { $0 / 3600 }, + foodType: entry.foodType, + createdByLoop: entry.createdByCurrentApp, + entry: entry) + } + } catch { + os_log("Carb fetch failed: %{public}@", log: log, type: .error, String(describing: error)) + throw error + } + } + + // MARK: - Apple Health + + private func healthSamples(_ type: HKQuantityType, _ interval: DateInterval?, options: HKQueryOptions = .strictStartDate) async throws -> [HKQuantitySample] { + guard let interval, interval.duration > 0 else { return [] } + let log = self.log + return try await withCheckedThrowingContinuation { continuation in + let predicate = HKQuery.predicateForSamples(withStart: interval.start, end: interval.end, options: options) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) + let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, error in + if let error { + os_log("Health query for %{public}@ failed: %{public}@", log: log, type: .error, type.identifier, String(describing: error)) + continuation.resume(throwing: error) + } else { + let quantities = (samples as? [HKQuantitySample]) ?? [] + // Match the local stores' half-open ranges, including at the Health/local boundary. Basal + // queries use no strict start so segments crossing into the interval can still be clamped. + continuation.resume(returning: options.contains(.strictStartDate) + ? quantities.filter { $0.startDate >= interval.start && $0.startDate < interval.end } + : quantities) + } + } + healthStore.execute(query) + } + } + + /// Several apps can write the same CGM reading to Health; keep one reading per 2½ minutes. + private func healthGlucose(_ interval: DateInterval?) async throws -> [GlucosePoint] { + let samples = try await healthSamples(HKQuantityType(.bloodGlucose), interval) + var points: [GlucosePoint] = [] + let own = HKSource.default().bundleIdentifier + for sample in samples where !sample.isDisplayOnly + && (FeatureFlags.observeHealthKitGlucoseSamplesFromOtherApps || sample.sourceRevision.source.bundleIdentifier == own) { + if let last = points.last, sample.startDate.timeIntervalSince(last.date) < 150 { continue } + points.append(GlucosePoint(date: sample.startDate, mgdl: sample.quantity.doubleValue(for: .milligramsPerDeciliter), + userEntered: sample.wasUserEntered)) + } + return points + } + + private func healthInsulin(_ interval: DateInterval?) async throws -> ([BolusEvent], [BasalSegment]) { + guard let interval else { return ([], []) } + let samples = try await healthSamples(HKQuantityType(.insulinDelivery), interval, options: []) + var boluses: [BolusEvent] = [] + var basal: [BasalSegment] = [] + let own = HKSource.default().bundleIdentifier + for sample in samples where FeatureFlags.observeHealthKitDoseSamplesFromOtherApps || sample.sourceRevision.source.bundleIdentifier == own { + let units = sample.quantity.doubleValue(for: .internationalUnit()) + let reason = (sample.metadata?[HKMetadataKeyInsulinDeliveryReason] as? NSNumber)?.intValue + let automaticFlag = (sample.metadata?["com.loopkit.InsulinKit.MetadataKeyAutomaticallyIssued"] as? NSNumber)?.boolValue + let manuallyEntered = (sample.metadata?["com.loopkit.InsulinKit.MetadataKeyManuallyEntered"] as? NSNumber)?.boolValue ?? false + let automatic = !manuallyEntered && (automaticFlag ?? false) + if reason == HKInsulinDeliveryReason.basal.rawValue { + if let segment = BasalSegment(healthSample: sample, in: interval) { + basal.append(segment) + } + } else if sample.startDate >= interval.start && sample.startDate < interval.end { + boluses.append(BolusEvent(id: sample.uuid.uuidString, date: sample.startDate, units: units, automatic: automatic, + manuallyEntered: manuallyEntered, + automaticKnown: BolusEvent.kindKnown(automatic: automaticFlag, manuallyEntered: manuallyEntered))) + } + } + return (boluses, basal) + } + + private func healthCarbs(_ interval: DateInterval?) async throws -> [CarbEvent] { + let samples = try await healthSamples(HKQuantityType(.dietaryCarbohydrates), interval) + let own = HKSource.default().bundleIdentifier + return samples + .filter { FeatureFlags.observeHealthKitCarbSamplesFromOtherApps || $0.sourceRevision.source.bundleIdentifier == own } + .map { sample in + CarbEvent(id: sample.uuid.uuidString, date: sample.startDate, grams: sample.quantity.doubleValue(for: .gram()), + absorptionHours: nil, foodType: sample.metadata?[HKMetadataKeyFoodType] as? String, + createdByLoop: sample.sourceRevision.source.bundleIdentifier == own) + } + } +} + +// MARK: - Historical targets + +extension LoopDataManager { + /// A historical chart must use the profile and presets from that time, rather than repeat today's targets. + func glucoseTargetBands(in interval: DateInterval, settingsStore: SettingsStore) async throws -> [TargetBand] { + var bands = try await LoopHistoryProvider.targetHistory(in: interval, from: settingsStore) + let overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: interval.start, endDate: interval.end) + for override in overrides.sorted(by: { $0.startDate < $1.startDate }) { + guard let target = override.settings.targetRange, + let active = override.activeInterval.intersection(with: interval), active.duration > 0 else { continue } + bands = bands.flatMap { band -> [TargetBand] in + var pieces: [TargetBand] = [] + if band.start < active.start { + pieces.append(TargetBand(start: band.start, end: min(band.end, active.start), range: band.range)) + } + if band.end > active.end { + pieces.append(TargetBand(start: max(band.start, active.end), end: band.end, range: band.range)) + } + return pieces + } + [TargetBand(start: active.start, end: active.end, + range: target.lowerBound.doubleValue(for: .milligramsPerDeciliter)...target.upperBound.doubleValue(for: .milligramsPerDeciliter))] + } + return bands.filter { $0.end > $0.start }.sorted { $0.start < $1.start } + } + +} diff --git a/Loop/Redesign/TrendsComponents.swift b/Loop/Redesign/TrendsComponents.swift new file mode 100644 index 0000000000..08e8346260 --- /dev/null +++ b/Loop/Redesign/TrendsComponents.swift @@ -0,0 +1,890 @@ +// +// TrendsComponents.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import Charts + +private typealias P = LoopDesign.Palette + +// MARK: - RangeSelector + +enum TrendsPeriod: Int, CaseIterable, Identifiable { + case day = 1 + case week = 7 + case twoWeeks = 14 + case month = 30 + case quarter = 90 + + var id: Int { rawValue } + + var label: String { + self == .day + ? NSLocalizedString("Day (Trends period)", value: "Day", comment: "Trends period picker: a period of one calendar day (not daytime)") + : String(format: NSLocalizedString("%1$@ d", comment: "Trends period picker, short label for a period of several days, e.g. 7 d. (1: number of days)"), + LoopFormat.number(Double(rawValue), decimals: 0)) + } + + var accessibilityName: String { + self == .day + ? NSLocalizedString("Day (Trends period)", value: "Day", comment: "Trends period picker: a period of one calendar day (not daytime)") + : String.localizedStringWithFormat(NSLocalizedString("%lld days", comment: "Trends period picker, VoiceOver name for a period of several days. (1: number of days)"), rawValue) + } + + /// The period ending on `endDay` (inclusive), cut off at `now` when it ends today. + func interval(endingOn endDay: Date, now: Date = Date(), calendar: Calendar = .current) -> DateInterval { + let endOfDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: endDay))! + let start = calendar.date(byAdding: .day, value: -rawValue, to: endOfDay)! + return DateInterval(start: start, end: min(endOfDay, now)) + } +} + +struct RangeSelectorView: View { + @Binding var period: TrendsPeriod + @Binding var endDay: Date + var now: Date = Date() + + private var calendar: Calendar { .current } + + private var canStepForward: Bool { + !calendar.isDate(endDay, inSameDayAs: now) && endDay < now + } + + var body: some View { + VStack(spacing: LoopDesign.Space.s3) { + Picker(NSLocalizedString("Period", comment: "Trends: VoiceOver label of the picker that chooses how many days the screen covers"), + selection: $period.animation(.snappy(duration: 0.2))) { + ForEach(TrendsPeriod.allCases) { option in + Text(option.label) + .accessibilityLabel(option.accessibilityName) + .tag(option) + } + } + .pickerStyle(.segmented) + + HStack { + StepButton(systemName: "chevron.left", label: NSLocalizedString("Previous period", comment: "VoiceOver label for the Trends button that moves back one period")) { step(-1) } + Spacer() + Text(LoopFormat.range(period.interval(endingOn: endDay, now: now).start, endDay)) + .font(LoopDesign.Typeface.headline.monospacedDigit()) + .foregroundStyle(P.ink) + Spacer() + StepButton(systemName: "chevron.right", label: NSLocalizedString("Next period", comment: "VoiceOver label for the Trends button that moves forward one period"), enabled: canStepForward) { step(1) } + } + } + } + + private func step(_ direction: Int) { + guard let next = calendar.date(byAdding: .day, value: direction * period.rawValue, to: endDay) else { return } + endDay = min(next, now) + } +} + +// MARK: - StatGrid + +struct StatTile: Identifiable { + let label: String + let value: String + let unit: String? + let detail: String? + var valueColor: Color = LoopDesign.Palette.ink + /// The consensus target for the figure, e.g. "aim < 7%". + var aim: String? = nil + /// When the figure is less representative than usual, e.g. GMI over a short period. + var caveat: String? = nil + var id: String { label } +} + +struct StatGridView: View { + @Environment(\.dynamicTypeSize) private var typeSize + let tiles: [StatTile] + + /// Pairs of tiles, so both tiles in a row take the taller one's height; one per row at accessibility sizes. + private var rows: [[StatTile]] { + let shown = Array(tiles.prefix(6)) + let perRow = typeSize.isAccessibilitySize ? 1 : 2 + return stride(from: 0, to: shown.count, by: perRow).map { Array(shown[$0.. some View { + VStack(alignment: .leading, spacing: 2) { + Text(tile.label) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(tile.value) + .font(LoopDesign.Typeface.statValue) + .foregroundStyle(tile.valueColor) + .minimumScaleFactor(0.7) + .lineLimit(1) + if let unit = tile.unit { + Text(unit) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + } + } + if let detail = tile.detail { + // Not capped: a detail can run to two lines of its own, which wrap again at large sizes. + Text(detail) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + .fixedSize(horizontal: false, vertical: true) + } + if let aim = tile.aim { + Text(aim) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + } + if let caveat = tile.caveat { + Text(caveat) + .font(LoopDesign.Typeface.caption) + .foregroundStyle(P.inkMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(LoopDesign.Space.s3) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(P.surfaceSunken, in: RoundedRectangle(cornerRadius: LoopDesign.Radius.md, style: .continuous)) + .accessibilityElement(children: .combine) + } +} + +// MARK: - TimeInRange + +extension LoopHistoryMath.RangeBand { + /// The band's name in the user's language. + var localizedName: String { + switch self { + case .veryHigh: return NSLocalizedString("Very high", comment: "Time in range band: glucose above 250 mg/dL (13.9 mmol/L)") + case .high: return NSLocalizedString("High", comment: "Time in range band: glucose 181–250 mg/dL (10.1–13.9 mmol/L)") + case .inRange: return NSLocalizedString("In range", comment: "Time in range band: glucose 70–180 mg/dL (3.9–10.0 mmol/L)") + case .low: return NSLocalizedString("Low", comment: "Time in range band: glucose 54–69 mg/dL (3.0–3.8 mmol/L)") + case .veryLow: return NSLocalizedString("Very low", comment: "Time in range band: glucose below 54 mg/dL (3.0 mmol/L)") + } + } + + /// The consensus target for the band. High and low are aims for all time above or below range, + /// so they say so; the others are the band's own. + var localizedTarget: String { + switch self { + case .veryHigh: return ConsensusTarget.under(5) + case .high: return String(format: NSLocalizedString("%1$@ above range", comment: "Time in range High row: the consensus target for all time above range, high and very high together. (1: target, e.g. < 25%)"), ConsensusTarget.under(25)) + case .inRange: return ConsensusTarget.over(70) + case .low: return String(format: NSLocalizedString("%1$@ below range", comment: "Time in range Low row: the consensus target for all time below range, low and very low together. (1: target, e.g. < 4%)"), ConsensusTarget.under(4)) + case .veryLow: return ConsensusTarget.under(1) + } + } + + /// Consensus thresholds with the user's decimal separator, e.g. "3,9–10,0". + func localizedThresholds(_ unit: GlucoseUnitStyle) -> String { + let decimals = unit.isMmol ? 1 : 0 + let n: (Double) -> String = { LoopFormat.number($0, decimals: decimals) } + switch (self, unit.isMmol) { + case (.veryHigh, true): return "> " + n(13.9) + case (.high, true): return n(10.1) + "–" + n(13.9) + case (.inRange, true): return n(3.9) + "–" + n(10.0) + case (.low, true): return n(3.0) + "–" + n(3.8) + case (.veryLow, true): return "< " + n(3.0) + case (.veryHigh, false): return "> " + n(250) + case (.high, false): return n(181) + "–" + n(250) + case (.inRange, false): return n(70) + "–" + n(180) + case (.low, false): return n(54) + "–" + n(69) + case (.veryLow, false): return "< " + n(54) + } + } +} + +/// "12%" in the user's language. +private func percentText(_ value: Double, decimals: Int = 0) -> String { + String(format: NSLocalizedString("%1$@%%", comment: "A percentage. (1: number)"), LoopFormat.number(value, decimals: decimals)) +} + +/// The international consensus targets for CGM metrics, written as "< 4%", "> 70%" or "≤ 36%". +enum ConsensusTarget { + static func under(_ percent: Double) -> String { "< " + percentText(percent) } + static func over(_ percent: Double) -> String { "> " + percentText(percent) } + static func atMost(_ percent: Double) -> String { "≤ " + percentText(percent) } + static func percent(_ percent: Double) -> String { percentText(percent) } + + /// "aim < 7%", under a figure. + static func aim(_ target: String) -> String { + String(format: NSLocalizedString("aim %1$@", comment: "Trends: the consensus target under a figure, in lower case. (1: target, e.g. < 7%)"), target) + } +} + +struct TimeInRangeView: View { + @Environment(\.dynamicTypeSize) private var typeSize + + /// Fractions per band, 0–1, of the time with CGM readings. + let fractions: [LoopHistoryMath.RangeBand: Double] + /// Hours a day with CGM readings, so a band's hours leave out time without them. + let hoursPerDay: Double + let unit: GlucoseUnitStyle + + private var percentages: [LoopHistoryMath.RangeBand: Int] { LoopHistoryMath.roundedPercentages(fractions) } + + static func color(_ band: LoopHistoryMath.RangeBand) -> Color { + switch band { + case .veryHigh: return GlucoseBand.veryHigh.agpColor + case .high: return GlucoseBand.high.agpColor + case .inRange: return GlucoseBand.target.agpColor + case .low: return GlucoseBand.low.agpColor + case .veryLow: return GlucoseBand.veryLow.agpColor + } + } + + var body: some View { + HStack(alignment: .top, spacing: LoopDesign.Space.s4) { + bar + .frame(width: 14) + VStack(alignment: .leading, spacing: LoopDesign.Space.s2) { + ForEach(LoopHistoryMath.RangeBand.allCases) { band in + row(band) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } + + private var bar: some View { + GeometryReader { geometry in + let minimum: CGFloat = 4 + let bands = LoopHistoryMath.RangeBand.allCases + let raw = bands.map { CGFloat(fractions[$0] ?? 0) } + let floors = raw.map { $0 > 0 ? max(minimum, $0 * geometry.size.height) : 0 } + let spare = geometry.size.height - floors.reduce(0, +) + // Trim the space the minimums borrowed from the largest band. + let largest = raw.indices.max { raw[$0] < raw[$1] } ?? 2 + VStack(spacing: 1) { + ForEach(Array(bands.enumerated()), id: \.offset) { index, band in + let height = index == largest ? max(0, floors[index] + spare) : floors[index] + if height > 0 { + Rectangle().fill(Self.color(band)).frame(height: max(0, height - 1)) + } + } + } + .clipShape(Capsule()) + } + .accessibilityHidden(true) + } + + private func row(_ band: LoopHistoryMath.RangeBand) -> some View { + let fraction = fractions[band] ?? 0 + let hours = fraction * hoursPerDay + let hoursText = String(format: NSLocalizedString("%1$@ h", comment: "Time in range row: average hours a day spent in the band, abbreviated. (1: hours with one decimal)"), + LoopFormat.number(hours, decimals: 1)) + let percent = Text(percentText(Double(percentages[band] ?? 0))) + .font(LoopDesign.Typeface.statValueSmall) + .foregroundStyle(P.ink) + let name = Text(band.localizedName) + .font(LoopDesign.Typeface.subhead) + .foregroundStyle(P.ink) + return Group { + if typeSize.isAccessibilitySize { + // One figure per line, so nothing is squeezed into a column. + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: LoopDesign.Space.s2) { + percent + name + } + Text(verbatim: "\(band.localizedThresholds(unit)) · \(hoursText) · \(band.localizedTarget)") + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack(alignment: .firstTextBaseline, spacing: LoopDesign.Space.s2) { + percent.frame(minWidth: 52, alignment: .trailing) + name + Text(band.localizedThresholds(unit)) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + Spacer(minLength: LoopDesign.Space.s1) + VStack(alignment: .trailing, spacing: 0) { + Text(hoursText) + .font(LoopDesign.Typeface.footnote.monospacedDigit()) + Text(band.localizedTarget) + .font(LoopDesign.Typeface.caption) + } + .foregroundStyle(P.inkMuted) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(format: NSLocalizedString("%1$@, %2$@ %3$@: %4$@ percent, %5$@ hours a day", comment: "VoiceOver label for a Time in range row. (1: band name, e.g. In range, 2: glucose thresholds, e.g. 3.9–10.0, 3: glucose unit, 4: percentage number, 5: average hours a day with one decimal)"), + band.localizedName, band.localizedThresholds(unit), unit.unitName, + LoopFormat.number(Double(percentages[band] ?? 0), decimals: 0), LoopFormat.number(hours, decimals: 1)) + + ", " + ConsensusTarget.aim(band.localizedTarget)) + } +} + +// MARK: - AGP + +struct TrendsAGPChart: View { + let slots: [LoopHistoryMath.AGPSlot] + let readings: [GlucosePoint] + let days: Int + let unit: GlucoseUnitStyle + let targetRange: ClosedRange? + var height: CGFloat = 180 + @Environment(\.dynamicTypeSize) private var typeSize + + private func v(_ mgdl: Double) -> Double { + min(max(unit.value(mgdl), unit.agpDomain.lowerBound), unit.agpDomain.upperBound) + } + + private static let hourTicks = [0, 360, 720, 1080, 1440] + + private static func label(minute: Int) -> String { + let minute = minute % 1440 + let date = Calendar.current.date(bySettingHour: minute / 60, minute: minute % 60, second: 0, of: Date())! + return DayAxis.hourLabel(date) + } + + var enoughDays: Bool { days >= 5 } + + var body: some View { + VStack(alignment: .leading, spacing: LoopDesign.Space.s2) { + if readings.isEmpty { + ChartPlaceholder(message: NSLocalizedString("No CGM data in this period", comment: "Trends chart placeholder when the period has no glucose readings"), height: height) + } else { + chart.frame(height: height) + if !enoughDays { + Text(String(format: NSLocalizedString("Not enough days for a pattern: %1$@ of 5", comment: "Trends Daily pattern footnote when fewer than 5 days have readings; the chart shows raw readings instead. (1: days with readings)"), + LoopFormat.number(Double(days), decimals: 0))) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + } + } + } + } + + private func minuteOfDay(_ date: Date) -> Int { + let parts = Calendar.current.dateComponents([.hour, .minute], from: date) + return (parts.hour ?? 0) * 60 + (parts.minute ?? 0) + } + + private var chart: some View { + Chart { + if let targetRange { + RectangleMark(xStart: .value("Start", 0), xEnd: .value("End", 1440), + yStart: .value("Low", v(targetRange.lowerBound)), yEnd: .value("High", v(targetRange.upperBound))) + .foregroundStyle(P.targetBand) + } + if enoughDays { + ForEach(slots, id: \.minute) { slot in + AreaMark(x: .value("Time", slot.minute), yStart: .value("5%", v(slot.p5)), yEnd: .value("95%", v(slot.p95)), series: .value("Band", "outer")) + .foregroundStyle(P.agpOuter) + .interpolationMethod(.monotone) + } + ForEach(slots, id: \.minute) { slot in + AreaMark(x: .value("Time", slot.minute), yStart: .value("25%", v(slot.p25)), yEnd: .value("75%", v(slot.p75)), series: .value("Band", "inner")) + .foregroundStyle(P.agpInner) + .interpolationMethod(.monotone) + } + ForEach(slots, id: \.minute) { slot in + LineMark(x: .value("Time", slot.minute), y: .value("Median", v(slot.p50)), series: .value("Band", "median")) + .foregroundStyle(P.glucose) + .lineStyle(StrokeStyle(lineWidth: 2.5)) + .interpolationMethod(.monotone) + } + } else { + ForEach(Array(readings.enumerated()), id: \.offset) { _, point in + PointMark(x: .value("Time", minuteOfDay(point.date)), y: .value("Glucose", v(point.mgdl))) + .symbolSize(8) + .foregroundStyle(P.glucose) + } + } + } + .chartXScale(domain: 0...1440) + .chartYScale(domain: unit.agpDomain) + .chartXAxis { + AxisMarks(values: typeSize.isAccessibilitySize ? [0, 720, 1440] : Self.hourTicks) { value in + AxisGridLine().foregroundStyle(P.grid) + AxisValueLabel { + if let minute = value.as(Int.self) { Text(Self.label(minute: minute)) } + } + .font(LoopDesign.Typeface.caption) + .foregroundStyle(P.inkMuted) + } + } + .chartYAxis { + AxisMarks(position: .leading, values: typeSize.isAccessibilitySize + ? [unit.agpTicks[0], unit.agpTicks[2], unit.agpTicks[4]] : unit.agpTicks) { _ in + AxisGridLine().foregroundStyle(P.grid) + AxisValueLabel().font(LoopDesign.Typeface.caption).foregroundStyle(P.inkMuted) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilitySummary) + } + + private var accessibilitySummary: String { + guard enoughDays, let high = slots.max(by: { $0.p50 < $1.p50 }), let low = slots.min(by: { $0.p50 < $1.p50 }) else { + return NSLocalizedString("Daily pattern: not enough days", comment: "VoiceOver label for the Trends Daily pattern chart when fewer than 5 days have readings") + } + let span = String.localizedStringWithFormat(NSLocalizedString("%lld days", comment: "A number of days, e.g. a Trends period or how many days are averaged. (1: number of days)"), days) + return String(format: NSLocalizedString("Daily pattern over %1$@. Median highest %2$@ at %3$@, lowest %4$@ at %5$@", comment: "VoiceOver label for the Trends Daily pattern chart. (1: days with readings, e.g. 14 days; 2: highest median glucose; 3: time of day; 4: lowest median glucose; 5: time of day)"), + span, unit.format(high.p50), Self.label(minute: high.minute), unit.format(low.p50), Self.label(minute: low.minute)) + } +} + +// MARK: - InsulinSplit + +/// Basal against bolus in one capsule: basal, then manual bolus, then automatic bolus. +struct InsulinSplitBar: View { + let split: InsulinSplit + /// A 6-point line for list headers. It is still tall enough to show the automatic bolus stripes. + var hairline = false + @Environment(\.colorScheme) private var scheme + + private enum Part: CaseIterable { case basal, manual, automatic } + + private func units(_ part: Part) -> Double { + switch part { + case .basal: return split.basal + // Unclassified bolus draws as plain bolus. + case .manual: return split.manualBolus + split.unclassifiedBolus + case .automatic: return split.automaticBolus + } + } + + var body: some View { + GeometryReader { geometry in + let parts = Part.allCases.filter { units($0) > 0 } + let gap: CGFloat = hairline ? 1.5 : 2.5 + let width = geometry.size.width - gap * CGFloat(max(0, parts.count - 1)) + let minimum: CGFloat = hairline ? 2 : 4 + let widths = parts.map { max(minimum, width * units($0) / max(split.total, .ulpOfOne)) } + // Take what the minimums add from the widest part, so the bar keeps its length. + let excess = widths.reduce(0, +) - width + let widest = widths.indices.max { widths[$0] < widths[$1] } + HStack(spacing: gap) { + ForEach(Array(parts.enumerated()), id: \.offset) { index, part in + segment(part).frame(width: max(0, widths[index] - (index == widest ? excess : 0))) + } + } + .clipShape(Capsule()) + } + .frame(height: hairline ? 6 : 14) + .accessibilityHidden(true) + } + + @ViewBuilder + private func segment(_ part: Part) -> some View { + switch part { + case .basal: + Rectangle().fill(LoopDesign.Palette.basal) + .overlay(alignment: .trailing) { + if !hairline { Rectangle().fill(LoopDesign.Palette.basalEdge).frame(width: 2) } + } + case .manual: + Rectangle().fill(LoopDesign.Palette.bolus) + case .automatic: + Rectangle().fill(Hatch.paint(LoopDesign.Palette.bolus, scheme: scheme)) + } + } +} + +/// Basal and bolus in units and shares, how the bolus divides, and the total. +struct InsulinSplitView: View { + let split: InsulinSplit + /// Today so far, rather than a whole day or a per-day average. + var partial = false + /// What the basal schedule delivers over the same time, shown under the basal row. + var scheduledBasal: Double? + /// How many of the days the schedule covers, e.g. "20 of 90 days", when that is fewer than the split's. + var scheduledNote: String? + /// Today against a usual day, e.g. "Usually 28.4 U by 19:46", under the total. + var usual: String? + /// The split as one capsule, where no chart above already draws it. + var showsBar = true + /// The total is the dashed average line of the chart above, so it carries that line's swatch. + var averageLine = false + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + VStack(alignment: .leading, spacing: LoopDesign.Space.s3) { + if showsBar { + InsulinSplitBar(split: split) + } + VStack(alignment: .leading, spacing: LoopDesign.Space.s2) { + row(.basal, NSLocalizedString("Basal", comment: "Basal and bolus row: insulin delivered as basal"), split.basal, split.basalPercent) + // No swatch: nothing on the chart draws the schedule. + if let scheduledBasal { + Text(Self.scheduledText(scheduledBasal, note: scheduledNote)) + .font(LoopDesign.Typeface.footnote.monospacedDigit()) + .foregroundStyle(LoopDesign.Palette.inkMuted) + .padding(.leading, 10 + LoopDesign.Space.s2) + } + row(.solid(LoopDesign.Palette.bolus), NSLocalizedString("Bolus", comment: "Basal and bolus row: insulin delivered as bolus, manual and automatic"), split.bolus, split.bolusPercent) + // Manual, automatic and unknown add up to the bolus row. Unknown has no swatch of its own: it is drawn + // as plain bolus, like manual. + if split.bolus >= 0.005 { + FlowLayout(spacing: LoopDesign.Space.s3, lineSpacing: 2) { + if showsBolusDetail { + part(.solid(LoopDesign.Palette.bolus), String(format: NSLocalizedString("Manual %1$@", comment: "Basal and bolus: bolus the person gave. (1: units, e.g. 16.2 U)"), LoopFormat.totalUnits(split.manualBolus))) + part(.hatch(LoopDesign.Palette.bolus), String(format: NSLocalizedString("Automatic %1$@", comment: "Basal and bolus: bolus Loop gave automatically. (1: units, e.g. 5.4 U)"), LoopFormat.totalUnits(split.automaticBolus))) + } + if !split.isClassified { + Text(Self.unknownText(split.unclassifiedBolus)) + } + } + .font(LoopDesign.Typeface.footnote.monospacedDigit()) + .foregroundStyle(LoopDesign.Palette.inkMuted) + .padding(.leading, 10 + LoopDesign.Space.s2) + } + Divider().padding(.vertical, 2) + HStack(alignment: .firstTextBaseline, spacing: LoopDesign.Space.s2) { + if averageLine { + LegendSwatch(swatch: .dash(LoopDesign.Palette.ink)).alignmentGuide(.firstTextBaseline) { $0[.bottom] + 3 } + } + Text(partial + ? NSLocalizedString("Total so far", comment: "Basal and bolus total row for today, which isn't over yet") + : NSLocalizedString("Total", comment: "Basal and bolus total row: basal plus bolus")) + .font(LoopDesign.Typeface.subhead.weight(.semibold)) + .foregroundStyle(LoopDesign.Palette.ink) + Spacer() + Text(LoopFormat.totalUnits(split.total)) + .font(LoopDesign.Typeface.statValueSmall) + .foregroundStyle(LoopDesign.Palette.ink) + } + if let usual { + Text(usual) + .font(LoopDesign.Typeface.footnote.monospacedDigit()) + .foregroundStyle(LoopDesign.Palette.inkMuted) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Self.accessibilityLabel(split, partial: partial, scheduledBasal: scheduledBasal.map { Self.scheduledText($0, note: scheduledNote) }, + showsBolusDetail: showsBolusDetail, usual: usual)) + } + + /// Manual and automatic, when any bolus says which it was; a split that is all unknown shows only that. + private var showsBolusDetail: Bool { + split.manualBolus + split.automaticBolus >= 0.005 + } + + /// "3.2 U unknown": bolus whose record doesn't say whether it was manual or automatic. + private static func unknownText(_ units: Double) -> String { + String(format: NSLocalizedString("%1$@ unknown", comment: "Basal and bolus: bolus whose record doesn't say whether it was manual or automatic, such as some in Apple Health, after the manual and automatic amounts. (1: units, e.g. 3.2 U)"), LoopFormat.totalUnits(units)) + } + + /// "Scheduled 18.0 U", or "Scheduled 18.0 U · 20 of 90 days" when the schedule is known for only some days. + private static func scheduledText(_ units: Double, note: String?) -> String { + let text = String(format: NSLocalizedString("Scheduled %1$@", comment: "Basal and bolus: what the basal schedule delivers over the same time, under the basal row. (1: units, e.g. 18.0 U)"), LoopFormat.totalUnits(units)) + return note.map { "\(text) · \($0)" } ?? text + } + + private func row(_ swatch: LegendItem.Swatch, _ label: String, _ units: Double, _ percent: Int) -> some View { + let amount = HStack(alignment: .firstTextBaseline, spacing: LoopDesign.Space.s2) { + Text(LoopFormat.totalUnits(units)) + .font(LoopDesign.Typeface.body.weight(.semibold).monospacedDigit()) + .foregroundStyle(LoopDesign.Palette.ink) + Text(verbatim: "\(percent)%") + .font(LoopDesign.Typeface.footnote.monospacedDigit()) + .foregroundStyle(LoopDesign.Palette.inkMuted) + .frame(minWidth: typeSize.isAccessibilitySize ? nil : 38, alignment: .trailing) + } + return AdaptiveRow(spacing: typeSize.isAccessibilitySize ? 0 : LoopDesign.Space.s2) { + HStack(alignment: .firstTextBaseline, spacing: LoopDesign.Space.s2) { + LegendSwatch(swatch: swatch).alignmentGuide(.firstTextBaseline) { $0[.bottom] - 1 } + Text(label) + .font(LoopDesign.Typeface.subhead) + .foregroundStyle(LoopDesign.Palette.ink) + } + if !typeSize.isAccessibilitySize { + Spacer() + } + amount + } + } + + private func part(_ swatch: LegendItem.Swatch, _ text: String) -> some View { + HStack(spacing: LoopDesign.Space.s1) { + LegendSwatch(swatch: swatch) + Text(text) + } + } + + static func accessibilityLabel(_ split: InsulinSplit, partial: Bool, scheduledBasal: String? = nil, showsBolusDetail: Bool = false, + usual: String? = nil) -> String { + var parts = [ + String(format: NSLocalizedString("Basal %1$@, %2$@ percent", comment: "VoiceOver: basal units and share. (1: units, 2: percentage number)"), LoopFormat.totalUnits(split.basal), "\(split.basalPercent)"), + String(format: NSLocalizedString("Bolus %1$@, %2$@ percent", comment: "VoiceOver: bolus units and share. (1: units, 2: percentage number)"), LoopFormat.totalUnits(split.bolus), "\(split.bolusPercent)"), + ] + if showsBolusDetail { + let text = String(format: NSLocalizedString("%1$@ manual, %2$@ automatic", comment: "VoiceOver: how the bolus divides. (1: manual units, 2: automatic units)"), + LoopFormat.totalUnits(split.manualBolus), LoopFormat.totalUnits(split.automaticBolus)) + parts.append(split.isClassified ? text : "\(text), \(unknownText(split.unclassifiedBolus))") + } else if !split.isClassified { + parts.append(unknownText(split.unclassifiedBolus)) + } + parts.append(String(format: partial + ? NSLocalizedString("Total so far %1$@", comment: "VoiceOver: today's total insulin so far. (1: units)") + : NSLocalizedString("Total %1$@", comment: "VoiceOver: total insulin. (1: units)"), + LoopFormat.totalUnits(split.total))) + if let scheduledBasal { + parts.insert(scheduledBasal, at: 1) + } + if let usual { + parts.append(usual) + } + return parts.joined(separator: ". ") + } +} + +// MARK: - InsulinDailyTotals + +struct InsulinDailyTotalsView: View { + let totals: [LoopHistoryMath.DayTotal] + /// Weekly averages instead of days, for periods past 30 days. + let weekly: Bool + /// Calculated from the original daily totals, even when the bars are weekly averages. + let average: Double + var height: CGFloat = 170 + @Environment(\.colorScheme) private var scheme + @Environment(\.dynamicTypeSize) private var typeSize + + private var yMax: Double { + let top = max(totals.map(\.total).max() ?? 0, average) + return max(10, Self.niceStep(top / 4) * 5) + } + + struct BolusPart: Equatable { + let start: Double + let end: Double + let automatic: Bool + } + + /// Where a day's bolus parts sit above its basal: manual (with bolus of unknown kind), then automatic. Each + /// starts a gap of the card's colour above the part below it, so no two parts touch. A part taller than two gaps + /// gives the gap from its own height; a shorter one moves up by a gap instead, so it stays visible. + static func bolusParts(_ day: LoopHistoryMath.DayTotal, gap: Double) -> [BolusPart] { + var parts: [BolusPart] = [] + var below = day.basal + for (units, automatic) in [(day.split.manualBolus + day.split.unclassifiedBolus, false), (day.split.automaticBolus, true)] where units > 0 { + let start = below + gap + let end = units > gap * 2 ? below + units : start + units + parts.append(BolusPart(start: start, end: end, automatic: automatic)) + below = end + } + return parts + } + + /// 1, 2 or 5 times a power of ten, at or above `raw`. + static func niceStep(_ raw: Double) -> Double { + guard raw > 0 else { return 1 } + let magnitude = pow(10, floor(log10(raw))) + let normalized = raw / magnitude + let nice: Double = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10 + return nice * magnitude + } + + var body: some View { + if totals.allSatisfy({ $0.total == 0 }) { + ChartPlaceholder(message: NSLocalizedString("No insulin recorded in this period", comment: "Trends Insulin per day chart placeholder when no insulin was delivered in the period"), height: height) + } else { + chart.frame(height: height) + } + } + + private var unitWidth: Calendar.Component { weekly ? .weekOfYear : .day } + + private var chart: some View { + Chart { + ForEach(totals) { day in + BarMark(x: .value("Day", day.day, unit: unitWidth), yStart: .value("Base", 0), yEnd: .value("Basal", day.basal), width: .ratio(0.7)) + .foregroundStyle(P.basal) + BarMark(x: .value("Day", day.day, unit: unitWidth), yStart: .value("Basal", max(0, day.basal - yMax * 0.006)), yEnd: .value("Edge", day.basal), width: .ratio(0.7)) + .foregroundStyle(P.basalEdge) + ForEach(Self.bolusParts(day, gap: yMax * 0.015), id: \.automatic) { part in + BarMark(x: .value("Day", day.day, unit: unitWidth), yStart: .value("Bolus start", part.start), yEnd: .value("Bolus end", part.end), width: .ratio(0.7)) + .foregroundStyle(part.automatic ? AnyShapeStyle(Hatch.paint(P.bolus, scheme: scheme)) : AnyShapeStyle(P.bolus)) + } + } + // The average's value is the Total row under the chart. + RuleMark(y: .value("Average", average)) + .foregroundStyle(P.ink) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + } + .chartYScale(domain: 0...yMax) + .chartXAxis { + AxisMarks(values: axisDates) { value in + AxisValueLabel(centered: !typeSize.isAccessibilitySize, + anchor: typeSize.isAccessibilitySize ? (value.index == 0 ? .topLeading : .topTrailing) : nil) { + if let date = value.as(Date.self) { + Text(axisLabel(date)) + .multilineTextAlignment(.center) + } + } + .font(LoopDesign.Typeface.caption) + .foregroundStyle(P.inkMuted) + } + } + .chartYAxis { + if typeSize.isAccessibilitySize { + AxisMarks(position: .leading, values: [0, yMax / 2, yMax]) { _ in + AxisGridLine().foregroundStyle(P.grid) + AxisValueLabel().font(LoopDesign.Typeface.caption).foregroundStyle(P.inkMuted) + } + } else { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(P.grid) + AxisValueLabel().font(LoopDesign.Typeface.caption).foregroundStyle(P.inkMuted) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(format: weekly + ? NSLocalizedString("Insulin per day, weekly average, average %1$@", comment: "VoiceOver label for the Trends Insulin per day chart when bars are weekly averages. (1: average insulin per day, e.g. 32.1 U)") + : NSLocalizedString("Insulin per day, average %1$@", comment: "VoiceOver label for the Trends Insulin per day chart. (1: average insulin per day, e.g. 32.1 U)"), + LoopFormat.totalUnits(average))) + } + + private var axisDates: [Date] { + // Full dates at the largest text sizes need the width between the two ends of the chart. + if typeSize.isAccessibilitySize, let first = totals.first?.day, let last = totals.last?.day { + return first == last ? [first] : [first, last] + } + if weekly { + let stride = max(1, Int(ceil(Double(totals.count) / 6))) + return totals.enumerated().compactMap { index, total in index % stride == 0 ? total.day : nil } + } + if totals.count > 14 { + return totals.map(\.day).filter { Calendar.current.component(.weekday, from: $0) == 2 } + } + return totals.map(\.day) + } + + private func axisLabel(_ date: Date) -> String { + if weekly { + return date.formatted(.dateTime.day().month(.abbreviated)) + } + let weekday = String(date.formatted(.dateTime.weekday(.narrow))) + if totals.count <= 14 { + let first = totals.first?.day == date, last = totals.last?.day == date + let isMonday = Calendar.current.component(.weekday, from: date) == 2 + return first || last || isMonday ? "\(weekday)\n\(date.formatted(.dateTime.day()))" : weekday + } + // 30 days: label every Monday only. + return Calendar.current.component(.weekday, from: date) == 2 ? date.formatted(.dateTime.day().month(.abbreviated)) : "" + } +} + +// MARK: - LoopModeBar + +struct LoopModeBarView: View { + let spans: [LoopModeSpan] + @Environment(\.dynamicTypeSize) private var typeSize + + private var total: TimeInterval { spans.reduce(0) { $0 + $1.duration } } + + static func color(_ mode: LoopModeSpan.Mode) -> Color { + switch mode { + case .closed: return P.statusAutomated + case .limited: return P.statusWarning + case .open: return P.inkMuted + } + } + + static func name(_ mode: LoopModeSpan.Mode) -> String { + switch mode { + case .closed: return NSLocalizedString("Closed loop", comment: "Trends Loop status: time Loop was running in closed loop") + case .limited: return NSLocalizedString("Limited", comment: "Trends Loop status: time closed loop was on but Loop wasn't completing runs") + case .open: return NSLocalizedString("Open loop / manual", comment: "Trends Loop status: time closed loop was switched off") + } + } + + private let modes: [LoopModeSpan.Mode] = [.closed, .limited, .open] + + var body: some View { + if total <= 0 { + ChartPlaceholder(message: NSLocalizedString("No loop runs recorded in this period", comment: "Trends Loop status placeholder when there is no Loop run history for the period"), height: 60) + } else { + VStack(spacing: LoopDesign.Space.s3) { + bar.frame(height: 10) + ForEach(modes, id: \.self) { mode in + row(mode) + } + } + } + } + + private func fraction(_ mode: LoopModeSpan.Mode) -> Double { + LoopHistoryMath.duration(of: mode, in: spans) / total + } + + private var bar: some View { + GeometryReader { geometry in + let minimum: CGFloat = 4 + let widths = modes.map { fraction($0) > 0 ? max(minimum, fraction($0) * geometry.size.width) : 0 } + let excess = widths.reduce(0, +) - geometry.size.width + HStack(spacing: 1) { + ForEach(Array(modes.enumerated()), id: \.offset) { index, mode in + if widths[index] > 0 { + Rectangle() + .fill(Self.color(mode)) + .frame(width: max(0, index == 0 ? widths[index] - excess - 2 : widths[index])) + } + } + } + .clipShape(Capsule()) + } + .accessibilityHidden(true) + } + + private func row(_ mode: LoopModeSpan.Mode) -> some View { + let duration = LoopHistoryMath.duration(of: mode, in: spans) + let name = HStack(spacing: LoopDesign.Space.s2) { + Circle().fill(Self.color(mode)).frame(width: 8, height: 8) + Text(Self.name(mode)) + .font(LoopDesign.Typeface.subhead) + .foregroundStyle(P.ink) + } + let time = Text(verbatim: "· \(LoopFormat.duration(duration))") + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + let percent = Text(percentText(fraction(mode) * 100, decimals: 1)) + .font(LoopDesign.Typeface.statValueSmall) + .foregroundStyle(P.ink) + return Group { + if typeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 0) { + name + HStack(alignment: .firstTextBaseline, spacing: LoopDesign.Space.s2) { + percent + time + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack(spacing: LoopDesign.Space.s2) { + name + time + Spacer() + percent + } + } + } + .accessibilityElement(children: .combine) + } +} diff --git a/Loop/Redesign/TrendsScreen.swift b/Loop/Redesign/TrendsScreen.swift new file mode 100644 index 0000000000..a13f831165 --- /dev/null +++ b/Loop/Redesign/TrendsScreen.swift @@ -0,0 +1,450 @@ +// +// TrendsScreen.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import UIKit + +private typealias P = LoopDesign.Palette + +/// The period review: everything on the screen follows one RangeSelector period. +@MainActor +final class TrendsModel: ObservableObject { + + struct Summary { + var interval: DateInterval + var unit: GlucoseUnitStyle + /// CGM readings only: typed-in fingersticks are left out of every figure. + var glucose: [GlucosePoint] = [] + /// Days from the first reading, when that is short of the period; nil when the readings span it. + var cgmCoveredDays: Int? + var stats: LoopHistoryMath.GlucoseStats? + var cgmActive: Double = 0 + /// Hours a day with CGM readings, which the time in range shares are of. + var cgmHoursPerDay: Double = 0 + var timeInRange: [LoopHistoryMath.RangeBand: Double] = [:] + var agp: [LoopHistoryMath.AGPSlot] = [] + var agpDays = 0 + var totals: [LoopHistoryMath.DayTotal] = [] + /// Carbs and bolus counts cover only the days per-day figures divide by (see `dataDays`). + var carbGrams = 0.0 + var carbEntries = 0 + var carbsByDay: [Date: Double] = [:] + var manualBoluses = 0 + var automaticBoluses = 0 + /// Boluses whose record doesn't say whether they were automatic or given by the person, such as some in Apple Health. + var unknownBoluses = 0 + var loopSpans: [LoopModeSpan] = [] + var targetRange: ClosedRange? + /// The whole calendar day, for a one-day period: drawn as one timeline. + var day: DayHistory? + var dayTargets: [TargetBand] = [] + /// What the basal schedule delivers per day, over the averaged days Loop has its schedule for. + var scheduledBasal: Double? + /// The averaged days `scheduledBasal` covers: Apple Health doesn't record the schedule for older days. + var scheduledDays = 0 + /// Some of the insulin comes from Apple Health, before Loop's own dose records begin. + var readsHealthInsulin = false + /// The median insulin by this time of day over the last 14 days, when the period ends now. + var typicalByNow: (typical: TypicalInsulinDelivery, at: Date)? + + /// The days per-day figures divide by: insulin's averaging days, or complete days with CGM data when there is + /// no insulin (today only when no complete day has any), never the period length. + var dataDays = 0 + var days: Double { Double(max(1, dataDays)) } + var insulinPerDay: Double { LoopHistoryMath.averageDailyInsulin(totals) } + /// Per day over the averaged days; a single day in progress when that is all there is. + var split: InsulinSplit { InsulinSplit.average(LoopHistoryMath.averagingDays(totals).map(\.split)) } + /// The split is today's total so far rather than an average of complete days. + var splitIsPartial: Bool { LoopHistoryMath.averagingDays(totals).allSatisfy { !$0.isComplete } } + } + + @Published var period: TrendsPeriod = .twoWeeks + @Published var endDay = Date() + @Published private(set) var summary: Summary? + @Published private(set) var loading = false + @Published private(set) var loadError: String? + + let provider: LoopHistoryProvider + private var loadTask: Task? + + init(provider: LoopHistoryProvider) { + self.provider = provider + } + + var interval: DateInterval { period.interval(endingOn: endDay) } + + func reload() { + loadTask?.cancel() + let interval = self.interval + let oneDay = period == .day + loading = true + summary = nil + loadError = nil + loadTask = Task { + do { + let result = try await Self.load(provider: provider, interval: interval, oneDay: oneDay) + guard !Task.isCancelled else { return } + self.summary = result + } catch { + guard !Task.isCancelled else { return } + self.loadError = error.localizedDescription + } + self.loading = false + } + } + + private static func load(provider: LoopHistoryProvider, interval: DateInterval, oneDay: Bool) async throws -> Summary { + var summary = Summary(interval: interval, unit: provider.unitStyle) + summary.targetRange = provider.glucoseTargetRange + + // A single day is read to midnight, as Today so far is, so the scheduled basal still to come is drawn. + let fetchWindow = oneDay ? DateInterval(start: interval.start, end: Calendar.current.date(byAdding: .day, value: 1, to: interval.start)!) : interval + async let historyFetch = provider.dayHistory(fetchWindow) + async let runsFetch = provider.loopRuns() + async let modeFetch = provider.loopModeChanges(in: interval) + async let manualTempFetch = provider.manualTempBasals(in: interval) + var history = try await historyFetch + let (runs, coverageStart) = try await runsFetch + if oneDay { + summary.day = history + summary.dayTargets = try await provider.glucoseTargetBands(in: fetchWindow) + history = history.clipped(to: interval) + } + + let cgm = history.glucose.filter { !$0.userEntered } + summary.glucose = cgm + summary.stats = LoopHistoryMath.glucoseStats(cgm) + summary.cgmActive = LoopHistoryMath.cgmActiveFraction(cgm, over: interval) + summary.cgmHoursPerDay = LoopHistoryMath.cgmHoursPerDay(cgmActive: summary.cgmActive, over: interval, + periodDays: LoopHistoryMath.days(in: interval).count) + summary.timeInRange = LoopHistoryMath.timeInRange(cgm) + summary.agp = LoopHistoryMath.agp(cgm) + summary.agpDays = LoopHistoryMath.daysWithReadings(cgm) + summary.cgmCoveredDays = LoopHistoryMath.cgmCoveredDays(cgm, in: interval, + periodDays: LoopHistoryMath.days(in: interval).count) + + let calendar = Calendar.current + summary.totals = LoopHistoryMath.days(in: interval).map { day in + let basal = history.basal.compactMap { $0.clamped(to: day) }.reduce(0) { $0 + $1.units } + let boluses = history.boluses.filter { $0.date >= day.start && $0.date < day.end } + return LoopHistoryMath.DayTotal(day: day.start, split: InsulinSplit(basal: basal, boluses: boluses), + isComplete: calendar.date(byAdding: .day, value: 1, to: day.start) == day.end) + } + let glucoseDays = Set(history.glucose.map { calendar.startOfDay(for: $0.date) }) + // Carbs and bolus counts share insulin's days, so a period with years of CGM data but a few days of Loop + // isn't spread thin. Without insulin, fall back to days with CGM data. + let withData = summary.totals.filter { $0.total > 0 || glucoseDays.contains($0.day) } + let insulinDays = LoopHistoryMath.averagingDays(summary.totals) + let averaged = !insulinDays.isEmpty ? insulinDays + : withData.contains(where: \.isComplete) ? withData.filter(\.isComplete) : withData + let averagedDays = Set(averaged.map(\.day)) + let inAveragedDays: (Date) -> Bool = { averagedDays.contains(calendar.startOfDay(for: $0)) } + summary.dataDays = averaged.count + summary.carbsByDay = Dictionary(grouping: history.carbs) { calendar.startOfDay(for: $0.date) } + .mapValues { $0.reduce(0) { $0 + $1.grams } } + let carbs = history.carbs.filter { inAveragedDays($0.date) } + summary.carbGrams = carbs.reduce(0) { $0 + $1.grams } + summary.carbEntries = carbs.count + let boluses = history.boluses.filter { inAveragedDays($0.date) } + summary.manualBoluses = boluses.filter { $0.automaticKnown && !$0.automatic }.count + summary.automaticBoluses = boluses.filter { $0.automaticKnown && $0.automatic }.count + summary.unknownBoluses = boluses.filter { !$0.automaticKnown }.count + + // Only days Loop has its schedule for: older days come from Health, which doesn't record it. + let spans = Dictionary(uniqueKeysWithValues: LoopHistoryMath.days(in: interval).map { ($0.start, $0) }) + let scheduled = insulinDays.compactMap { day in spans[day.day].flatMap { LoopHistoryMath.scheduledUnits(history.scheduledBasal, over: $0) } } + if !scheduled.isEmpty { + summary.scheduledBasal = scheduled.reduce(0, +) / Double(scheduled.count) + summary.scheduledDays = scheduled.count + } + summary.readsHealthInsulin = interval.start < history.localInsulinStart + if interval.end.timeIntervalSinceNow > -300 { + summary.typicalByNow = await provider.typicalInsulinByNow() + } + + summary.loopSpans = LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, modeChanges: try await modeFetch, + coverageStart: coverageStart, manualTempBasals: try await manualTempFetch) + return summary + } + + // MARK: Tiles + + func tiles(_ s: Summary) -> [StatTile] { + let unit = s.unit + var tiles: [StatTile] = [] + let averageLabel = NSLocalizedString("Average", comment: "Trends summary tile: mean glucose over the period") + let gmiLabel = NSLocalizedString("GMI", comment: "Trends summary tile: abbreviation for Glucose Management Indicator, an estimate of HbA1c from CGM data") + let gmiCaveat = LoopHistoryMath.gmiIsRepresentative(periodDays: s.totals.count, cgmActive: s.cgmActive) ? nil + : String(format: NSLocalizedString("Best over 14 days with %1$@ CGM data", comment: "Trends GMI tile caption when the period is shorter than 14 days or CGM readings cover less than 70% of it: the consensus for when GMI is representative. (1: share of CGM data, e.g. 70%)"), + ConsensusTarget.percent(70)) + if let stats = s.stats { + tiles.append(StatTile(label: averageLabel, value: unit.format(stats.meanMgdl), unit: unit.unitName, + detail: String(format: NSLocalizedString("SD %1$@ · CV %2$@%%", comment: "Trends Average tile detail: standard deviation and coefficient of variation of glucose. (1: standard deviation in the glucose unit, 2: coefficient of variation as a percentage number)"), + unit.format(stats.sdMgdl), LoopFormat.number(stats.cv * 100, decimals: 0)), + aim: averageAim(unit))) + // Always shown, as Statistics did. The consensus says how representative it is: 14 days, 70% CGM data. + tiles.append(StatTile(label: gmiLabel, value: LoopFormat.number(stats.gmiPercent, decimals: 1), unit: "%", + detail: String(format: NSLocalizedString("%1$@ mmol/mol", comment: "Trends GMI tile detail: the GMI in IFCC units. (1: value)"), + LoopFormat.number(stats.gmiMmolMol, decimals: 0)), + aim: ConsensusTarget.aim(ConsensusTarget.under(7)), + caveat: gmiCaveat)) + } else { + tiles.append(StatTile(label: averageLabel, value: "–", unit: unit.unitName, + detail: NSLocalizedString("No CGM data", comment: "Trends Average tile detail when the period has no glucose readings"))) + tiles.append(StatTile(label: gmiLabel, value: "–", unit: "%", + detail: String(format: NSLocalizedString("%1$@ mmol/mol", comment: "Trends GMI tile detail: the GMI in IFCC units. (1: value)"), "–"), + caveat: gmiCaveat)) + } + // One day's figures are that day's own, so its tiles drop "per day" and show whole counts. + let oneDay = s.day != nil + let countDecimals = oneDay ? 0 : 1 + tiles.append(StatTile(label: oneDay + ? NSLocalizedString("Carbs", comment: "Trends summary tile for a one-day period: the day's total carbohydrate") + : NSLocalizedString("Carbs per day", comment: "Trends summary tile: average grams of carbohydrate entered per day"), + value: LoopFormat.number(s.carbGrams / s.days, decimals: 0), + unit: NSLocalizedString("g", comment: "Abbreviation for grams"), + detail: oneDay + ? String(format: NSLocalizedString("%lld entries", comment: "Trends Carbs tile detail for a one-day period: how many carb entries were made that day. (1: number)"), s.carbEntries) + : String(format: NSLocalizedString("%1$@ entries", comment: "Trends Carbs per day tile detail: average number of carb entries per day, a fractional value such as 2.5, so not pluralised. (1: number with one decimal)"), + LoopFormat.number(Double(s.carbEntries) / s.days, decimals: 1)))) + tiles.append(StatTile(label: oneDay + ? NSLocalizedString("Boluses", comment: "Trends summary tile for a one-day period: how many boluses were given that day, other than Loop's automatic ones") + : NSLocalizedString("Boluses per day", comment: "Trends summary tile: average number of boluses per day, other than Loop's automatic ones"), + // Every bolus not known to be automatic, so one of unknown kind still counts. + value: LoopFormat.number(Double(s.manualBoluses + s.unknownBoluses) / s.days, decimals: countDecimals), unit: nil, + detail: s.unknownBoluses > 0 + ? String(format: NSLocalizedString("%1$@ of unknown kind\n+ %2$@ automatic", comment: "Trends Boluses per day tile detail when some boluses don't say whether they were manual or automatic, such as some in Apple Health, on two lines: how many of the figure above are of unknown kind, then the automatic boluses on top. (1: boluses of unknown kind per day; 2: automatic boluses per day; both fractional, such as 4.2)"), + LoopFormat.number(Double(s.unknownBoluses) / s.days, decimals: countDecimals), + LoopFormat.number(Double(s.automaticBoluses) / s.days, decimals: countDecimals)) + : String(format: NSLocalizedString("+ %1$@ automatic", comment: "Trends Boluses per day tile detail: average number of automatic boluses per day on top of the manual ones, a fractional value such as 4.2. (1: number with one decimal)"), + LoopFormat.number(Double(s.automaticBoluses) / s.days, decimals: countDecimals)))) + return tiles + } + + /// "aim < 8.6, CV ≤ 36%": the consensus targets for mean glucose (154 mg/dL) and its variability. + private func averageAim(_ unit: GlucoseUnitStyle) -> String { + String(format: NSLocalizedString("aim %1$@, CV %2$@", comment: "Trends Average tile: the consensus targets for average glucose and its coefficient of variation, in lower case. (1: glucose target, e.g. < 8.6; 2: CV target, e.g. ≤ 36%)"), + "< " + (unit.isMmol ? LoopFormat.number(8.6, decimals: 1) : "154"), ConsensusTarget.atMost(36)) + } + + /// "5 of 14 days" when only part of the period is averaged: days without data, or today while it runs. + func daysNote(_ s: Summary) -> String? { + let insulinDays = LoopHistoryMath.averagingDays(s.totals).count + guard insulinDays < s.totals.count else { return nil } + let total = String.localizedStringWithFormat(NSLocalizedString("%lld days", comment: "A number of days, e.g. a Trends period or how many days are averaged. (1: number of days)"), s.totals.count) + return String(format: NSLocalizedString("%1$@ of %2$@", comment: "Trends: how many days of the period are averaged. (1: days averaged, a number; 2: days in the period, e.g. 14 days)"), LoopFormat.number(Double(insulinDays), decimals: 0), total) + } +} + +// MARK: - Screen + +struct TrendsView: View { + @StateObject private var model: TrendsModel + + /// Closes Trends when it is presented on its own; nil where Trends has nowhere to return to. + private let done: (() -> Void)? + + init(provider: LoopHistoryProvider, done: (() -> Void)? = nil) { + _model = StateObject(wrappedValue: TrendsModel(provider: provider)) + self.done = done + } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: LoopDesign.Space.s6, pinnedViews: [.sectionHeaders]) { + Section { + content + } header: { + RangeSelectorView(period: $model.period, endDay: $model.endDay) + .padding(.vertical, LoopDesign.Space.s2) + .background(P.surface) + } + } + .padding(.horizontal, LoopDesign.Space.s4) + .padding(.bottom, LoopDesign.Space.s6) + } + .background(P.surface) + .navigationTitle(NSLocalizedString("Trends", comment: "Title of the Trends tab")) + .refreshable { model.reload() } + .toolbar { + if let done { + ToolbarItem(placement: .confirmationAction) { + Button(NSLocalizedString("Done", comment: "Button to close Trends"), action: done) + } + } + } + } + .onAppear { model.reload() } + .onChange(of: model.period) { _, _ in model.reload() } + .onChange(of: model.endDay) { _, _ in model.reload() } + } + + @ViewBuilder + private var content: some View { + if let s = model.summary { + summaryCard(s) + // A single day leads with its timeline. + if let day = s.day { + dayCard(day, targets: s.dayTargets, unit: s.unit) + } + LoopCard { + CardHeader(title: NSLocalizedString("Time in range", comment: "Trends card title: share of glucose readings in each range")) { + Text(String(format: NSLocalizedString("%1$@%% CGM active", comment: "Trends Time in range card: share of the period with CGM readings. (1: percentage number)"), + LoopFormat.number(s.cgmActive * 100, decimals: 0)) + + " · " + ConsensusTarget.aim(ConsensusTarget.over(70))) + } + if s.glucose.isEmpty { + ChartPlaceholder(message: NSLocalizedString("No CGM data in this period", comment: "Trends chart placeholder when the period has no glucose readings")) + } else { + TimeInRangeView(fractions: s.timeInRange, hoursPerDay: s.cgmHoursPerDay, unit: s.unit) + } + } + if s.day == nil { + patternCard(s) + } + insulinCard(s) + LoopCard { + CardHeader(title: NSLocalizedString("Loop status", comment: "Trends card title: how long Loop spent in closed loop, limited or open loop")) + LoopModeBarView(spans: s.loopSpans) + if let note = coverageNote(s) { + Footnote(note) + } + } + } else if let error = model.loadError { + LoopCard { + ChartPlaceholder(message: String(format: NSLocalizedString("Couldn't load Trends: %1$@", comment: "Trends error message when the history can't be read. (1: error description)"), error)) + Button(NSLocalizedString("Retry", comment: "Button to try loading Trends again after an error")) { model.reload() } + } + } else { + ProgressView().frame(maxWidth: .infinity, minHeight: 300) + } + } + + /// Insulin per day with its basal and bolus split, or one day's insulin under its timeline. + private func insulinCard(_ s: TrendsModel.Summary) -> some View { + let weekly = s.totals.count > 30 + return LoopCard { + if s.day != nil { + // One day is one bar, so the timeline above stands in for the chart. + CardHeader(title: NSLocalizedString("Insulin", comment: "Trends card title for a one-day period: the day's insulin, split into basal and bolus")) { + Text(s.splitIsPartial + ? NSLocalizedString("today so far", comment: "Trends Basal and bolus card subtitle when the figures are today's total so far") + : NSLocalizedString("this day", comment: "Trends Basal and bolus card subtitle when the period is one complete day")) + } + } else { + CardHeader(title: NSLocalizedString("Insulin per day", comment: "Trends tile and card title: average total insulin per day")) { + if weekly, let note = model.daysNote(s) { + Text(String(format: NSLocalizedString("per day, weekly average · %1$@", comment: "Trends Insulin per day card subtitle when each bar is a week's average daily total and only some days of the period are averaged. (1: days averaged, e.g. 4 of 90 days)"), note)) + } else if weekly { + Text(NSLocalizedString("per day, weekly average", comment: "Trends Insulin per day card subtitle when each bar is a week's average daily total")) + } else if let note = model.daysNote(s) { + Text(String(format: NSLocalizedString("average of %1$@", comment: "Trends Insulin per day card subtitle when only some days of the period are averaged, such as a day still in progress. (1: days averaged, e.g. 13 of 14 days)"), note)) + } else { + Text(NSLocalizedString("average per day", comment: "Trends Basal and bolus card subtitle when the figures are averaged per day over the period")) + } + } + } + if s.split.total > 0 { + if s.day == nil { + InsulinDailyTotalsView(totals: weekly ? LoopHistoryMath.weekly(s.totals) : s.totals, weekly: weekly, average: s.insulinPerDay) + } + // Under the chart, the rows name its marks, so there is no separate legend. + let averaged = LoopHistoryMath.averagingDays(s.totals).count + InsulinSplitView(split: s.split, partial: s.splitIsPartial, scheduledBasal: s.scheduledBasal, + scheduledNote: s.scheduledDays < averaged ? daysOf(s.scheduledDays, averaged) : nil, + usual: s.day != nil && s.splitIsPartial ? usualByNow(s) : nil, showsBar: s.day != nil, + averageLine: s.day == nil) + if s.day == nil, let today = todaySoFar(s) { + Footnote(today) + } + // Days read from Apple Health have no schedule, and some boluses there don't say which kind they were. + if s.readsHealthInsulin, s.scheduledBasal == nil || !s.split.isClassified { + Footnote(NSLocalizedString("Older days come from Apple Health, which doesn't record the basal schedule, and some boluses there don't say whether they were manual or automatic.", comment: "Trends Insulin per day footnote when the period reaches back past Loop's own records into Apple Health, so scheduled basal can't be shown, or some boluses there have no record of whether they were manual or automatic")) + } + } else { + ChartPlaceholder(message: NSLocalizedString("No insulin recorded in this period", comment: "Trends Insulin per day chart placeholder when no insulin was delivered in the period"), height: 80) + } + } + } + + /// The same timeline as Today so far: glucose, every bolus and basal change, and carbs, through the day. + private func dayCard(_ day: DayHistory, targets: [TargetBand], unit: GlucoseUnitStyle) -> some View { + let isToday = Calendar.current.isDateInToday(day.window.start) + return DayTimelineCard(title: isToday ? DayTimelineCard.todayTitle : LoopFormat.weekdayDay(day.window.start), + history: day, unit: unit, targetBands: targets, height: 260) + } + + private func patternCard(_ s: TrendsModel.Summary) -> some View { + LoopCard { + CardHeader(title: NSLocalizedString("Daily pattern", comment: "Trends card title: the ambulatory glucose profile, glucose by time of day across the period")) { Text(s.unit.unitName) } + TrendsAGPChart(slots: s.agp, readings: s.glucose, days: s.agpDays, unit: s.unit, targetRange: s.targetRange) + ChartLegend(items: [ + LegendItem(swatch: .solid(P.glucose), label: NSLocalizedString("Median", comment: "Trends Daily pattern legend: the median glucose line")), + LegendItem(swatch: .solid(P.agpInner), label: NSLocalizedString("25–75%", comment: "Trends Daily pattern legend: band between the 25th and 75th percentiles of glucose")), + LegendItem(swatch: .solid(P.agpOuter), label: NSLocalizedString("5–95%", comment: "Trends Daily pattern legend: band between the 5th and 95th percentiles of glucose")), + ]) + } + } + + private func summaryCard(_ s: TrendsModel.Summary) -> some View { + LoopCard { + CardHeader(title: NSLocalizedString("Summary", comment: "Trends card title: the summary statistics tiles")) + if let covered = s.cgmCoveredDays { + Footnote(String(format: NSLocalizedString("CGM data covers %1$@.", comment: "Trends Summary note when glucose readings start partway through the period. (1: days covered, e.g. 5 of 14 days)"), + daysOf(covered, s.totals.count))) + } + StatGridView(tiles: model.tiles(s)) + .opacity(model.loading ? 0.5 : 1) + } + } + + /// "5 of 14 days". + private func daysOf(_ count: Int, _ total: Int) -> String { + let days = String.localizedStringWithFormat(NSLocalizedString("%lld days", comment: "A number of days, e.g. a Trends period or how many days are averaged. (1: number of days)"), total) + return String(format: NSLocalizedString("%1$@ of %2$@", comment: "Trends: how many days of the period are averaged. (1: days averaged, a number; 2: days in the period, e.g. 14 days)"), LoopFormat.number(Double(count), decimals: 0), days) + } + + /// "Usually 28.4 U by 19:46": today's insulin against the last 14 days at the same time. + private func usualByNow(_ s: TrendsModel.Summary) -> String? { + guard let usual = s.typicalByNow else { return nil } + return String(format: NSLocalizedString("Usually %1$@ by %2$@", comment: "Trends: the median insulin delivered by this time of day over the last 14 days. (1: units, e.g. 28.4 U, 2: time of day)"), + LoopFormat.totalUnits(usual.typical.median), LoopFormat.time(usual.at)) + } + + /// Today's bar is still growing, so say where it stands against a usual day. + private func todaySoFar(_ s: TrendsModel.Summary) -> String? { + guard let today = s.totals.last, !today.isComplete, today.total > 0, let usual = s.typicalByNow else { return nil } + return String(format: NSLocalizedString("Today so far %1$@, usually %2$@ by %3$@.", comment: "Trends Insulin per day footnote: today's insulin so far against the median by this time of day over the last 14 days. (1: today's units, 2: usual units, 3: time of day)"), + LoopFormat.totalUnits(today.total), LoopFormat.totalUnits(usual.typical.median), LoopFormat.time(usual.at)) + } + + /// When Loop's records of its runs and settings start inside the period, say so, and how long it keeps them. + private func coverageNote(_ s: TrendsModel.Summary) -> String? { + guard let start = s.loopSpans.first?.start, start > s.interval.start else { return nil } + let kept = String.localizedStringWithFormat(NSLocalizedString("%lld days", comment: "A number of days, e.g. a Trends period or how many days are averaged. (1: number of days)"), + model.provider.localHistoryDays) + return String(format: NSLocalizedString("Covers %1$@, from the start of Loop's records, which it keeps for %2$@.", comment: "Trends Loop status footnote when Loop's records of its runs and settings start partway through the period. (1: date range covered; 2: how long Loop keeps them, e.g. 90 days)"), + LoopFormat.range(start, s.interval.end), kept) + } +} + +/// A card's quiet closing line: a target, a caveat or a comparison. +private struct Footnote: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(LoopDesign.Typeface.footnote) + .foregroundStyle(P.inkMuted) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 626ae21e28..9762fe1efe 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -120,6 +120,8 @@ class SettingsViewModel { @ObservationIgnored var insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? + @ObservationIgnored var historyProvider: LoopHistoryProvider? + @ObservationIgnored var setupHistory: (() async throws -> SetupHistory)? @ObservationIgnored var outcomesPackageBuilder: OutcomesPackageBuilder? diff --git a/Loop/View Models/StatisticsViewModel.swift b/Loop/View Models/StatisticsViewModel.swift deleted file mode 100644 index d2078f2017..0000000000 --- a/Loop/View Models/StatisticsViewModel.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// StatisticsViewModel.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKit -import LoopCore -import LoopAlgorithm - -@MainActor -final class StatisticsViewModel: ObservableObject { - - /// Selectable look-back windows. Bounded by the on-device glucose cache. - enum DateRange: Int, CaseIterable, Identifiable { - case week = 7 - case twoWeeks = 14 - case month = 30 - case quarter = 90 - - var id: Int { rawValue } - var days: Int { rawValue } - } - - @Published var selectedRange: DateRange = .twoWeeks { - didSet { - guard selectedRange != oldValue else { return } - recompute() - } - } - - @Published private(set) var statistics: GlucoseStatistics? - @Published private(set) var isLoading = false - /// Days of glucose actually available (bounded by data accumulated and the cache). - @Published private(set) var availableDays: Double = 0 - - /// Complete days of insulin delivery within the selected range, oldest first. - @Published private(set) var insulinDays: [DailyInsulinDelivery] = [] - @Published private(set) var insulinToday: DailyInsulinDelivery? - @Published private(set) var typicalInsulinByNow: TypicalInsulinDelivery? - - private var allSamples: [StoredGlucoseSample] = [] - private var allInsulinDays: [DailyInsulinDelivery] = [] - private let glucoseStore: GlucoseStoreProtocol - private let insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? - private let calendar: Calendar - private let now: () -> Date - - /// Configured local cache duration (how far back glucose is retained on device). - let cacheDuration: TimeInterval - let scheduledBasalTotal: Double? - - init(glucoseStore: GlucoseStoreProtocol, - insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? = nil, - scheduledBasalTotal: Double? = nil, - cacheDuration: TimeInterval = Bundle.main.localCacheDuration, - calendar: Calendar = .current, - now: @escaping () -> Date = { Date() }) { - self.glucoseStore = glucoseStore - self.insulinDeliveryHistory = insulinDeliveryHistory - self.scheduledBasalTotal = scheduledBasalTotal - self.cacheDuration = cacheDuration - self.calendar = calendar - self.now = now - } - - var showsInsulin: Bool { insulinDeliveryHistory != nil } - - /// Mean of the complete days in the selected range. - var averageInsulinDay: InsulinDeliveryTotals? { - guard !insulinDays.isEmpty else { return nil } - let count = Double(insulinDays.count) - let sum = insulinDays.reduce(into: InsulinDeliveryTotals()) { sum, day in - sum.basal += day.totals.basal - sum.automaticBolus += day.totals.automaticBolus - sum.manualBolus += day.totals.manualBolus - } - return InsulinDeliveryTotals(basal: sum.basal / count, automaticBolus: sum.automaticBolus / count, manualBolus: sum.manualBolus / count) - } - - var cacheDurationDays: Int { Int((cacheDuration / 86400).rounded()) } - - /// Info notices for the selected range: shown when the window is incomplete, - /// with an extra line when the local cache is what's limiting the history. - /// Empty when there's enough data to fill the selected window. - var dataNotices: [String] { - guard availableDays + 1 < Double(selectedRange.days) else { return [] } - let have = max(0, Int(availableDays.rounded())) - var notices = [String(format: NSLocalizedString( - "Showing %1$d days of data — not enough for the full %2$d-day period yet.", - comment: "Notice when the selected statistics range has incomplete data"), have, selectedRange.days)] - if cacheDurationDays < selectedRange.days { - notices.append(String(format: NSLocalizedString( - "This device keeps %d days of glucose. Increase the cache duration and rebuild to review the full period.", - comment: "Notice when the local cache limits the statistics history"), cacheDurationDays)) - } - return notices - } - - func load() async { - isLoading = true - defer { isLoading = false } - - let end = now() - let start = end.addingTimeInterval(-cacheDuration) - do { - allSamples = try await glucoseStore.getGlucoseSamples(start: start, end: end) - .filter { !$0.isDisplayOnly && !$0.wasUserEntered } - .sorted { $0.startDate < $1.startDate } - } catch { - allSamples = [] - } - availableDays = allSamples.first.map { end.timeIntervalSince($0.startDate) / 86400 } ?? 0 - - if let insulinDeliveryHistory, let history = try? await insulinDeliveryHistory(cacheDurationDays) { - let days = history.dailyTotals(calendar: calendar) - allInsulinDays = days.filter(\.isComplete) - insulinToday = days.last(where: { !$0.isComplete }) - typicalInsulinByNow = history.typicalDelivery(by: history.end, calendar: calendar) - } - recompute() - } - - /// Recompute statistics for the selected window from the already-loaded samples - /// (no store round-trip), so switching ranges is instant. - private func recompute() { - let end = now() - let start = calendar.date(byAdding: .day, value: -selectedRange.days, to: end) - ?? end.addingTimeInterval(-Double(selectedRange.days) * 24 * 60 * 60) - statistics = GlucoseStatistics(samples: allSamples, start: start, end: end, calendar: calendar) - let firstDay = calendar.date(byAdding: .day, value: -selectedRange.days, to: calendar.startOfDay(for: end)) ?? start - insulinDays = allInsulinDays.filter { $0.start >= firstDay } - } -} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 70f6d26b4e..7dc0d4ef21 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -55,6 +55,7 @@ struct SettingsView: View { case favoriteFoods case presets + case trends } } @@ -156,6 +157,11 @@ struct SettingsView: View { } case .favoriteFoods: FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) + case .trends: + if let historyProvider = viewModel.historyProvider { + // Trends brings its own navigation stack, so it is a sheet rather than a push. Done closes only Trends. + TrendsView(provider: historyProvider, done: { self.sheet = nil }) + } } } .environmentObject(displayGlucosePreference) @@ -341,22 +347,19 @@ extension SettingsView { private var statisticsSection: some View { if let glucoseStore = viewModel.deviceManager?.glucoseStore { Section { - NavigationLink(destination: StatisticsView( - glucoseStore: glucoseStore, - insulinDeliveryHistory: viewModel.insulinDeliveryHistory, - scheduledBasalTotal: viewModel.therapySettings().basalRateSchedule?.total() - )) { + if viewModel.historyProvider != nil { LargeButton( - action: {}, - includeArrow: false, - imageView: Image(systemName: "chart.xyaxis.line") + action: { sheet = .trends }, + // Opens a sheet, but sits among navigation rows, so it shows their chevron. + arrowLikeNavigationLink: true, + imageView: Image(systemName: "chart.bar.doc.horizontal") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30), - label: NSLocalizedString("Statistics", comment: "Statistics settings button text"), - descriptiveText: NSLocalizedString("Glucose overview, ambulatory glucose profile and daily insulin", comment: "Statistics settings descriptive text") + label: NSLocalizedString("Trends", comment: "Trends settings button text"), + descriptiveText: NSLocalizedString("Time in range, daily pattern, insulin per day and Loop status", comment: "Trends settings descriptive text") ) - .accessibilityIdentifier("settingsViewStatistics") + .accessibilityIdentifier("settingsViewTrends") } NavigationLink(destination: CurrentSetupView(viewModel: CurrentSetupViewModel( selected: setupChange, @@ -789,6 +792,7 @@ fileprivate struct LargeButton: View { let action: () -> Void var includeArrow: Bool + var arrowLikeNavigationLink: Bool let imageView: Content let secondaryImageView: SecondaryContent let label: String @@ -797,6 +801,7 @@ fileprivate struct LargeButton: View { init( action: @escaping () -> Void, includeArrow: Bool = true, + arrowLikeNavigationLink: Bool = false, imageView: Content, secondaryImageView: SecondaryContent = EmptyView(), label: String, @@ -804,6 +809,7 @@ fileprivate struct LargeButton: View { ) { self.action = action self.includeArrow = includeArrow + self.arrowLikeNavigationLink = arrowLikeNavigationLink self.imageView = imageView self.secondaryImageView = secondaryImageView self.label = label @@ -840,7 +846,14 @@ fileprivate struct LargeButton: View { if includeArrow { // TODO: Ick. I can't use a NavigationLink because we're not Navigating, but this seems worse somehow. - Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) + if arrowLikeNavigationLink { + Image(systemName: "chevron.forward") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color(uiColor: .tertiaryLabel)) + .padding(.trailing, 1) + } else { + Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) + } } } .padding(EdgeInsets(top: topBottomPadding, leading: 0, bottom: topBottomPadding, trailing: 0)) diff --git a/Loop/Views/Statistics/AGPChartView.swift b/Loop/Views/Statistics/AGPChartView.swift deleted file mode 100644 index 55bac2b930..0000000000 --- a/Loop/Views/Statistics/AGPChartView.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// AGPChartView.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopKit -import LoopUI - -/// The ambulatory glucose profile, styled after the IDC / captūrAGP report. -/// -/// The percentile bands (5–95% and 25–75%) are each built once as a single -/// continuous shape, then filled with the glucose-zone colors (green target, -/// gold high, orange very-high, red lows) by clipping each color to that zone's -/// horizontal stripe. Because every zone clips the *same* band path, the colored -/// segments always line up at the zone boundaries. Drawn with a `Canvas` for -/// full control over the clipping. Glucose is plotted in the user's display unit. -struct AGPChartView: View { - let profile: [AGPBand] - let unit: LoopUnit - var targetLowMgDL: Double = 70 - var targetHighMgDL: Double = 180 - - private struct Zone { let lo: Double, hi: Double, band: GlucoseBand } - - private var topMgDL: Double { max(350, (profile.map { $0.p95 }.max() ?? 350).rounded(.up)) } - private var zones: [Zone] { - [Zone(lo: 0, hi: 54, band: .veryLow), - Zone(lo: 54, hi: 70, band: .low), - Zone(lo: 70, hi: 180, band: .target), - Zone(lo: 180, hi: 250, band: .high), - Zone(lo: 250, hi: topMgDL, band: .veryHigh)] - } - - private func display(_ mgdl: Double) -> Double { - LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl).doubleValue(for: unit) - } - private func yLabel(_ mgdl: Double) -> String { - let v = display(mgdl) - return unit == .milligramsPerDeciliter ? "\(Int(v.rounded()))" : String(format: "%.1f", v) - } - - private let leftInset: CGFloat = 34 - private let bottomInset: CGFloat = 18 - private let topInset: CGFloat = 6 - private let rightInset: CGFloat = 6 - private let medianColor = Color(red: 0.16, green: 0.40, blue: 0.20) - - /// Append a smooth (Catmull-Rom) curve through `points` to a path already - /// positioned at `points[0]`. - static func addSmoothCurve(through points: [CGPoint], to path: inout Path) { - guard points.count > 1 else { return } - for i in 0..<(points.count - 1) { - let p0 = points[max(0, i - 1)] - let p1 = points[i] - let p2 = points[i + 1] - let p3 = points[min(points.count - 1, i + 2)] - let c1 = CGPoint(x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6) - let c2 = CGPoint(x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6) - path.addCurve(to: p2, control1: c1, control2: c2) - } - } - - var body: some View { - Canvas { ctx, size in - let plot = CGRect(x: leftInset, y: topInset, - width: size.width - leftInset - rightInset, - height: size.height - topInset - bottomInset) - guard plot.width > 0, plot.height > 0 else { return } - let pts = profile.sorted { $0.timeOfDay < $1.timeOfDay } - guard !pts.isEmpty else { return } - let top = topMgDL - - func x(_ hour: Double) -> CGFloat { plot.minX + CGFloat(hour / 24) * plot.width } - func y(_ mgdl: Double) -> CGFloat { plot.minY + CGFloat(1 - mgdl / top) * plot.height } - - func bandPath(_ lo: (AGPBand) -> Double, _ hi: (AGPBand) -> Double) -> Path { - let topPts = pts.map { CGPoint(x: x($0.timeOfDay / 3600), y: y(hi($0))) } - let botPts = pts.reversed().map { CGPoint(x: x($0.timeOfDay / 3600), y: y(lo($0))) } - var p = Path() - p.move(to: topPts[0]) - Self.addSmoothCurve(through: topPts, to: &p) - p.addLine(to: botPts[0]) - Self.addSmoothCurve(through: botPts, to: &p) - p.closeSubpath() - return p - } - let outer = bandPath({ $0.p05 }, { $0.p95 }) - let inner = bandPath({ $0.p25 }, { $0.p75 }) - - // Y gridlines. - for v in [54.0, 70, 180, 250, top] { - var grid = Path() - grid.move(to: CGPoint(x: plot.minX, y: y(v))) - grid.addLine(to: CGPoint(x: plot.maxX, y: y(v))) - ctx.stroke(grid, with: .color(.gray.opacity(0.25)), lineWidth: 0.5) - } - - // Bands: fill each glucose zone's color, clipped to that zone's stripe. - for z in zones { - let stripe = CGRect(x: plot.minX, y: y(z.hi), width: plot.width, height: y(z.lo) - y(z.hi)) - ctx.drawLayer { layer in - layer.clip(to: Path(stripe)) - layer.fill(outer, with: .color(z.band.agpColor.opacity(0.35))) - layer.fill(inner, with: .color(z.band.agpColor.opacity(0.75))) - } - } - - // Target-range boundaries. - for t in [targetLowMgDL, targetHighMgDL] { - var line = Path() - line.move(to: CGPoint(x: plot.minX, y: y(t))) - line.addLine(to: CGPoint(x: plot.maxX, y: y(t))) - ctx.stroke(line, with: .color(GlucoseBand.target.agpColor), lineWidth: 1.5) - } - - // Median. - let medPts = pts.map { CGPoint(x: x($0.timeOfDay / 3600), y: y($0.p50)) } - var med = Path() - med.move(to: medPts[0]) - Self.addSmoothCurve(through: medPts, to: &med) - ctx.stroke(med, with: .color(medianColor), lineWidth: 2.5) - - // Y-axis labels. - for v in [54.0, 70, 180, 250, top] { - ctx.draw(Text(yLabel(v)).font(.caption2).foregroundColor(.secondary), - at: CGPoint(x: plot.minX - 4, y: y(v)), anchor: .trailing) - } - // X-axis labels. - for (hour, label) in [(0, "12a"), (6, "6a"), (12, "12p"), (18, "6p"), (24, "12a")] { - ctx.draw(Text(label).font(.caption2).foregroundColor(.secondary), - at: CGPoint(x: x(Double(hour)), y: plot.maxY + 9), anchor: .center) - } - } - .accessibilityLabel(Text(NSLocalizedString("Daily glucose pattern", comment: "Accessibility label for the daily glucose pattern (AGP) chart"))) - } -} - -#if DEBUG -#Preview { - let bands: [AGPBand] = (0..<48).map { i in - let hour = Double(i) * 0.5 - let base = 150.0 + 55 * sin((hour - 4) / 24 * 2 * .pi) - let spread = 25 + 18 * (1 + sin((hour - 8) / 24 * 2 * .pi)) - return AGPBand( - timeOfDay: (Double(i) + 0.5) * 1800, - p05: max(45, base - spread * 1.8), p25: base - spread * 0.7, - p50: base, p75: base + spread * 0.8, p95: base + spread * 1.9 - ) - } - return AGPChartView(profile: bands, unit: .milligramsPerDeciliter) - .frame(height: 260) - .padding() -} -#endif diff --git a/Loop/Views/Statistics/DailyInsulinChartView.swift b/Loop/Views/Statistics/DailyInsulinChartView.swift deleted file mode 100644 index e372747da3..0000000000 --- a/Loop/Views/Statistics/DailyInsulinChartView.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// DailyInsulinChartView.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import Charts -import LoopKitUI - -/// Insulin delivered per calendar day, stacked by basal, automatic bolus and manual bolus, -/// with the average as a dashed line. -struct DailyInsulinChartView: View { - let days: [DailyInsulinDelivery] - let average: Double? - - @Environment(\.insulinTintColor) private var insulinTintColor - - private enum Part: CaseIterable { - case basal, automaticBolus, manualBolus - - var name: String { - switch self { - case .basal: - return NSLocalizedString("Basal", comment: "Daily insulin chart legend for basal delivery") - case .automaticBolus: - return NSLocalizedString("Automatic Bolus", comment: "Daily insulin chart legend for automatic boluses") - case .manualBolus: - return NSLocalizedString("Manual Bolus", comment: "Daily insulin chart legend for manual boluses") - } - } - - func units(_ totals: InsulinDeliveryTotals) -> Double { - switch self { - case .basal: return totals.basal - case .automaticBolus: return totals.automaticBolus - case .manualBolus: return totals.manualBolus - } - } - } - - private var labelStride: Int { - switch days.count { - case ...8: return 1 - case ...15: return 2 - case ...31: return 7 - default: return 14 - } - } - - var body: some View { - Chart { - ForEach(days) { day in - ForEach(Part.allCases, id: \.self) { part in - BarMark( - x: .value("Day", day.start, unit: .day), - y: .value("Units", part.units(day.totals)) - ) - .foregroundStyle(by: .value("Delivery", part.name)) - } - } - if let average { - RuleMark(y: .value("Average", average)) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) - .foregroundStyle(Color.primary.opacity(0.6)) - } - } - .chartForegroundStyleScale([ - Part.basal.name: insulinTintColor.opacity(0.35), - Part.automaticBolus.name: insulinTintColor.opacity(0.65), - Part.manualBolus.name: insulinTintColor, - ]) - .chartXAxis { - AxisMarks(values: .stride(by: .day, count: labelStride)) { - AxisGridLine() - AxisValueLabel(format: .dateTime.month(.defaultDigits).day(), centered: true) - } - } - .chartLegend(position: .bottom, alignment: .leading) - .accessibilityLabel(Text(NSLocalizedString("Insulin delivered per day", comment: "Accessibility label for the daily insulin chart"))) - } -} diff --git a/Loop/Views/Statistics/GlucoseBand+Display.swift b/Loop/Views/Statistics/GlucoseBand+Display.swift new file mode 100644 index 0000000000..a088563197 --- /dev/null +++ b/Loop/Views/Statistics/GlucoseBand+Display.swift @@ -0,0 +1,61 @@ +// +// GlucoseBand+Display.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopUI + +/// Writes one band edge in the unit the user reads glucose in. +/// +/// The consensus thresholds are defined in mg/dL, but the AGP report publishes +/// its own rounded mmol/L edges rather than conversions of them: converting +/// 181 mg/dL yields 10.046, which prints as "10.0" and collides with the top of +/// the target band. So each edge carries both spellings, and only the number +/// formatting — decimal separator included — is left to the preference. +private func bandEdge(mgdl: Double, mmol: Double, _ preference: DisplayGlucosePreference) -> String { + let quantity = preference.unit == .millimolesPerLiter + ? LoopQuantity(unit: .millimolesPerLiter, doubleValue: mmol) + : LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl) + return preference.format(quantity, includeUnit: false) +} + +/// captūrAGP / IDC report palette for the glucose bands, matched to the standard +/// Ambulatory Glucose Profile report colors. In dark mode each band is lifted so it +/// stays clear against a dark card, and very low becomes the brighter red. +extension GlucoseBand { + var agpColor: Color { + switch self { + case .veryLow: return Self.color(light: (0.60, 0.13, 0.15), dark: (1.00, 0.27, 0.23)) // dark red + case .low: return Self.color(light: (0.85, 0.26, 0.24), dark: (1.00, 0.62, 0.60)) // red + case .target: return Self.color(light: (0.36, 0.65, 0.37), dark: (0.20, 0.84, 0.29)) // green + case .high: return Self.color(light: (0.91, 0.77, 0.26), dark: (1.00, 0.84, 0.04)) // gold / yellow + case .veryHigh: return Self.color(light: (0.85, 0.55, 0.24), dark: (0.79, 0.54, 0.00)) // orange + } + } + + private static func color(light: (Double, Double, Double), dark: (Double, Double, Double)) -> Color { + Color(uiColor: UIColor { traits in + let (red, green, blue) = traits.userInterfaceStyle == .dark ? dark : light + return UIColor(red: red, green: green, blue: blue, alpha: 1) + }) + } + + /// The band's range, written in the unit the user reads glucose in. + func rangeDescription(_ preference: DisplayGlucosePreference) -> String { + func edge(_ mgdl: Double, _ mmol: Double) -> String { + bandEdge(mgdl: mgdl, mmol: mmol, preference) + } + switch self { + case .veryLow: return "<\(edge(54, 3.0))" + case .low: return "\(edge(54, 3.0))–\(edge(69, 3.8))" + case .target: return "\(edge(70, 3.9))–\(edge(180, 10.0))" + case .high: return "\(edge(181, 10.1))–\(edge(250, 13.9))" + case .veryHigh: return ">\(edge(250, 13.9))" + } + } +} diff --git a/Loop/Views/Statistics/SetupComparisonCharts.swift b/Loop/Views/Statistics/SetupComparisonCharts.swift index d6e104f0f6..0341036f4d 100644 --- a/Loop/Views/Statistics/SetupComparisonCharts.swift +++ b/Loop/Views/Statistics/SetupComparisonCharts.swift @@ -82,7 +82,7 @@ struct MedianComparisonChartView: View { var path = Path() if let first = points.first { path.move(to: first) - AGPChartView.addSmoothCurve(through: points, to: &path) + addSmoothCurve(through: points, to: &path) } return path } @@ -99,7 +99,7 @@ struct MedianComparisonChartView: View { var band = curve(afterSorted, \.p75) let lower = afterSorted.reversed().map { CGPoint(x: x($0), y: y($0.p25)) } band.addLine(to: lower[0]) - AGPChartView.addSmoothCurve(through: lower, to: &band) + addSmoothCurve(through: lower, to: &band) band.closeSubpath() ctx.fill(band, with: .color(Color.accentColor.opacity(0.15))) } @@ -118,3 +118,18 @@ struct MedianComparisonChartView: View { .accessibilityLabel(Text(NSLocalizedString("Median glucose by time of day, before and since the change", comment: "Accessibility label for the setup comparison chart"))) } } + +/// Append a smooth (Catmull-Rom) curve through `points` to a path already +/// positioned at `points[0]`. +private func addSmoothCurve(through points: [CGPoint], to path: inout Path) { + guard points.count > 1 else { return } + for i in 0..<(points.count - 1) { + let p0 = points[max(0, i - 1)] + let p1 = points[i] + let p2 = points[i + 1] + let p3 = points[min(points.count - 1, i + 2)] + let c1 = CGPoint(x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6) + let c2 = CGPoint(x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6) + path.addCurve(to: p2, control1: c1, control2: c2) + } +} diff --git a/Loop/Views/Statistics/StatisticsView.swift b/Loop/Views/Statistics/StatisticsView.swift deleted file mode 100644 index 752ba229eb..0000000000 --- a/Loop/Views/Statistics/StatisticsView.swift +++ /dev/null @@ -1,239 +0,0 @@ -// -// StatisticsView.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopKit -import LoopCore -import LoopKitUI -import LoopUI - -/// "How am I doing?" overview — an Ambulatory Glucose Profile report: summary -/// metrics, a time-in-range breakdown, and the 24-hour percentile AGP chart, -/// over a selectable look-back window. -struct StatisticsView: View { - @StateObject private var viewModel: StatisticsViewModel - @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - - init(glucoseStore: GlucoseStoreProtocol, insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? = nil, scheduledBasalTotal: Double? = nil) { - _viewModel = StateObject(wrappedValue: StatisticsViewModel(glucoseStore: glucoseStore, insulinDeliveryHistory: insulinDeliveryHistory, scheduledBasalTotal: scheduledBasalTotal)) - } - - var body: some View { - List { - Section { - Picker(NSLocalizedString("Range", comment: "Statistics date-range picker label"), - selection: $viewModel.selectedRange) { - ForEach(StatisticsViewModel.DateRange.allCases) { range in - Text("\(range.days)d").tag(range) - } - } - .pickerStyle(.segmented) - } - - if !viewModel.dataNotices.isEmpty { - Section { - ForEach(viewModel.dataNotices, id: \.self) { line in - Label { - Text(line) - } icon: { - Image(systemName: "info.circle") - } - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - - if let stats = viewModel.statistics, stats.sampleCount > 0 { - metricsSection(stats) - timeInRangeSection(stats) - agpSection(stats) - } else if viewModel.isLoading { - Section { - HStack { Spacer(); ProgressView(); Spacer() } - } - } else { - Section { - Text(NSLocalizedString("No glucose data for this period.", comment: "Statistics empty state")) - .foregroundColor(.secondary) - } - } - - if viewModel.showsInsulin { - insulinSection - } - } - .navigationTitle(Text(NSLocalizedString("Statistics", comment: "Statistics screen title"))) - .navigationBarTitleDisplayMode(.inline) - .task { await viewModel.load() } - } - - // MARK: - Sections - - private func metricsSection(_ stats: GlucoseStatistics) -> some View { - let averageGoal = displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 154)) - return Section(header: Text(NSLocalizedString("Glucose Metrics", comment: "Glucose metrics section header"))) { - LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { - metric(NSLocalizedString("GMI", comment: "Glucose Management Indicator metric title"), - value: stats.gmi.map { String(format: "%.1f%%", $0) }, - goal: NSLocalizedString("Goal: <7%", comment: "GMI goal")) - metric(NSLocalizedString("Average", comment: "Average glucose metric title"), - value: stats.averageGlucose.map { - displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0)) - }, - goal: String(format: NSLocalizedString("Goal: <%@", comment: "Average glucose goal"), averageGoal)) - metric(NSLocalizedString("Variability", comment: "Coefficient of variation metric title"), - value: stats.coefficientOfVariation.map { String(format: "%.0f%%", $0) }, - goal: NSLocalizedString("Goal: ≤36%", comment: "Glucose variability goal")) - metric(NSLocalizedString("CGM Active", comment: "Percent of time CGM data present metric title"), - value: String(format: "%.0f%%", stats.percentActive * 100), - goal: NSLocalizedString("Goal: >70%", comment: "CGM active goal")) - } - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) - } - } - - private func metric(_ title: String, value: String?, goal: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.subheadline.weight(.semibold)) - Text(value ?? "–") - .font(.system(.title, design: .rounded).weight(.bold)) - .monospacedDigit() - .minimumScaleFactor(0.6) - .lineLimit(1) - Text(goal) - .font(.caption2) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - } - - private func timeInRangeSection(_ stats: GlucoseStatistics) -> some View { - Section(header: Text(NSLocalizedString("Time in Range", comment: "Time-in-range section header"))) { - TimeInRangeBar(timeInRange: stats.timeInRange) - .padding(.vertical, 8) - } - } - - private func units(_ value: Double) -> String { - String(format: NSLocalizedString("%@ U", comment: "Insulin amount in units (1: number)"), value.formatted(.number.precision(.fractionLength(1)))) - } - - private func percent(_ part: Double, of total: Double) -> String { - total > 0 ? (part / total).formatted(.percent.precision(.fractionLength(0))) : "–" - } - - private var todayCaption: String { - guard let typical = viewModel.typicalInsulinByNow, let today = viewModel.insulinToday else { - return NSLocalizedString("Since midnight", comment: "Caption for insulin delivered today when there is no usual amount to compare") - } - return String(format: NSLocalizedString("Usually %1$@ by %2$@", comment: "Caption comparing today's insulin with the usual amount by this time (1: units, 2: time of day)"), - units(typical.median), today.end.formatted(date: .omitted, time: .shortened)) - } - - private var insulinSection: some View { - let average = viewModel.averageInsulinDay - return Section(header: Text(NSLocalizedString("Daily Insulin", comment: "Daily insulin section header")), - footer: Text(NSLocalizedString("Each bar is one calendar day; the dashed line is the average. Today isn't included until the day is over. Usual amounts are the median of the last 14 days at the same time of day.", comment: "Daily insulin section explanation"))) { - LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { - metric(NSLocalizedString("Average", comment: "Average daily insulin metric title"), - value: average.map { units($0.total) }, - goal: String(format: NSLocalizedString("Per day, over %d days", comment: "Caption for average daily insulin (1: number of days)"), viewModel.insulinDays.count)) - metric(NSLocalizedString("Today", comment: "Insulin delivered today metric title"), - value: viewModel.insulinToday.map { units($0.totals.total) }, - goal: todayCaption) - metric(NSLocalizedString("Basal", comment: "Average daily basal metric title"), - value: average.map { units($0.basal) }, - goal: average.map { average in - if let scheduled = viewModel.scheduledBasalTotal { - return String(format: NSLocalizedString("%1$@ · scheduled %2$@", comment: "Caption for average daily basal (1: percent of total, 2: scheduled basal units)"), percent(average.basal, of: average.total), units(scheduled)) - } - return percent(average.basal, of: average.total) - } ?? "–") - metric(NSLocalizedString("Bolus", comment: "Average daily bolus metric title"), - value: average.map { units($0.bolus) }, - goal: average.map { average in - String(format: NSLocalizedString("%1$@ · %2$@ automatic", comment: "Caption for average daily bolus (1: percent of total, 2: automatic bolus units)"), percent(average.bolus, of: average.total), units(average.automaticBolus)) - } ?? "–") - } - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) - - if !viewModel.insulinDays.isEmpty { - DailyInsulinChartView(days: viewModel.insulinDays, average: average?.total) - .frame(height: 220) - .padding(.vertical, 8) - } - } - } - - @ViewBuilder - private func agpSection(_ stats: GlucoseStatistics) -> some View { - Section(header: Text(NSLocalizedString("Daily Glucose Pattern", comment: "Daily glucose pattern (AGP) section header")), - footer: Text(NSLocalizedString("Median (line) with 25–75% and 5–95% bands, by time of day.", comment: "AGP chart explanation"))) { - if stats.agpProfile.isEmpty { - Text(NSLocalizedString("Not enough data to plot a profile.", comment: "AGP empty state")) - .foregroundColor(.secondary) - } else { - AGPChartView(profile: stats.agpProfile, unit: displayGlucosePreference.unit) - .frame(height: 240) - .padding(.vertical, 8) - } - } - } -} - -#if DEBUG -/// Generates a daily glucose pattern with day-to-day spread so the AGP bands and -/// metrics populate in the canvas. Deterministic (seeded) so previews are stable. -private final class PreviewGlucoseStore: GlucoseStoreProtocol { - private let samples: [StoredGlucoseSample] - - init() { - var seed: UInt64 = 0x9E3779B97F4A7C15 - func unit01() -> Double { - seed = seed &* 6364136223846793005 &+ 1442695040888963407 - return Double(seed >> 11) / Double(UInt64(1) << 53) - } - let cal = Calendar.current - let now = Date() - let cadence: TimeInterval = 15 * 60 - var t = now.addingTimeInterval(-14 * 24 * 60 * 60) - var out: [StoredGlucoseSample] = [] - while t < now { - let hour = t.timeIntervalSince(cal.startOfDay(for: t)) / 3600 - let base = 140.0 + 40 * sin((hour - 4) / 24 * 2 * .pi) - let value = max(45, base + (unit01() - 0.5) * 70) - out.append(StoredGlucoseSample(startDate: t, quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value))) - t = t.addingTimeInterval(cadence) - } - samples = out - } - - var latestGlucose: GlucoseSampleValue? { samples.last } - - func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] { - samples.filter { (start == nil || $0.startDate >= start!) && (end == nil || $0.startDate < end!) } - } - - func addGlucoseSamples(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] { [] } -} - -#Preview { - NavigationView { - StatisticsView(glucoseStore: PreviewGlucoseStore()) - .environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) - } -} -#endif diff --git a/Loop/Views/Statistics/TimeInRangeBar.swift b/Loop/Views/Statistics/TimeInRangeBar.swift deleted file mode 100644 index 171c28b041..0000000000 --- a/Loop/Views/Statistics/TimeInRangeBar.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// TimeInRangeBar.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopKit -import LoopKitUI -import LoopUI - -/// Writes one band edge in the unit the user reads glucose in. -/// -/// The consensus thresholds are defined in mg/dL, but the AGP report publishes -/// its own rounded mmol/L edges rather than conversions of them: converting -/// 181 mg/dL yields 10.046, which prints as "10.0" and collides with the top of -/// the target band. So each edge carries both spellings, and only the number -/// formatting — decimal separator included — is left to the preference. -private func bandEdge(mgdl: Double, mmol: Double, _ preference: DisplayGlucosePreference) -> String { - let quantity = preference.unit == .millimolesPerLiter - ? LoopQuantity(unit: .millimolesPerLiter, doubleValue: mmol) - : LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl) - return preference.format(quantity, includeUnit: false) -} - -/// captūrAGP / IDC report palette for the glucose bands, matched to the standard -/// Ambulatory Glucose Profile report colors. -extension GlucoseBand { - var agpColor: Color { - switch self { - case .veryLow: return Color(red: 0.60, green: 0.13, blue: 0.15) // dark red - case .low: return Color(red: 0.85, green: 0.26, blue: 0.24) // red - case .target: return Color(red: 0.36, green: 0.65, blue: 0.37) // green - case .high: return Color(red: 0.91, green: 0.77, blue: 0.26) // gold / yellow - case .veryHigh: return Color(red: 0.85, green: 0.55, blue: 0.24) // orange - } - } - - var localizedTitle: String { - switch self { - case .veryLow: return NSLocalizedString("Very Low", comment: "Time-in-range band label (<54 mg/dL)") - case .low: return NSLocalizedString("Low", comment: "Time-in-range band label (54–69 mg/dL)") - case .target: return NSLocalizedString("In Range", comment: "Time-in-range band label (70–180 mg/dL)") - case .high: return NSLocalizedString("High", comment: "Time-in-range band label (181–250 mg/dL)") - case .veryHigh: return NSLocalizedString("Very High", comment: "Time-in-range band label (>250 mg/dL)") - } - } - - /// The band's range, written in the unit the user reads glucose in. - func rangeDescription(_ preference: DisplayGlucosePreference) -> String { - func edge(_ mgdl: Double, _ mmol: Double) -> String { - bandEdge(mgdl: mgdl, mmol: mmol, preference) - } - switch self { - case .veryLow: return "<\(edge(54, 3.0))" - case .low: return "\(edge(54, 3.0))–\(edge(69, 3.8))" - case .target: return "\(edge(70, 3.9))–\(edge(180, 10.0))" - case .high: return "\(edge(181, 10.1))–\(edge(250, 13.9))" - case .veryHigh: return ">\(edge(250, 13.9))" - } - } - - /// The consensus per-band goal, where the standard report lists one. - var goalText: String? { - switch self { - case .veryHigh: return NSLocalizedString("Goal: <5%", comment: "TIR goal for very high") - case .target: return NSLocalizedString("Goal: >70%", comment: "TIR goal for target range") - case .veryLow: return NSLocalizedString("Goal: <1%", comment: "TIR goal for very low") - default: return nil - } - } -} - -/// A vertical stacked time-in-range bar (Very High at top → Very Low at bottom) -/// with per-band percentages, consensus goals, and the standard clinical helper -/// notes, styled after the IDC / captūrAGP report. -struct TimeInRangeBar: View { - let timeInRange: [GlucoseBand: Double] - - @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - - // Top-to-bottom display order. - private static let order: [GlucoseBand] = [.veryHigh, .high, .target, .low, .veryLow] - - private func fraction(_ band: GlucoseBand) -> Double { timeInRange[band] ?? 0 } - private func percent(_ band: GlucoseBand) -> Int { Int(fraction(band) * 100 + 0.5) } - private var aboveRange: Int { Int((fraction(.veryHigh) + fraction(.high)) * 100 + 0.5) } - private var belowRange: Int { Int((fraction(.low) + fraction(.veryLow)) * 100 + 0.5) } - - /// A target-range edge, written in the user's unit. - private func edge(_ mgdl: Double, _ mmol: Double) -> String { - bandEdge(mgdl: mgdl, mmol: mmol, displayGlucosePreference) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .top, spacing: 16) { - // Vertical stacked bar. - GeometryReader { geo in - VStack(spacing: 0) { - ForEach(Self.order, id: \.self) { band in - band.agpColor - .frame(height: max(0, geo.size.height * fraction(band))) - } - Spacer(minLength: 0) - } - .clipShape(RoundedRectangle(cornerRadius: 5)) - } - .frame(width: 26) - - // Per-band rows: name + range on the left, percentage with its - // goal directly beneath it on the right. - VStack(alignment: .leading, spacing: 10) { - ForEach(Self.order, id: \.self) { band in - HStack(alignment: .top, spacing: 8) { - RoundedRectangle(cornerRadius: 2) - .fill(band.agpColor) - .frame(width: 10, height: 10) - .padding(.top, 3) - VStack(alignment: .leading, spacing: 1) { - Text(band.localizedTitle).font(.subheadline.weight(.medium)) - Text(band.rangeDescription(displayGlucosePreference)).font(.caption2).foregroundColor(.secondary) - } - Spacer() - VStack(alignment: .trailing, spacing: 1) { - Text("\(percent(band))%").font(.subheadline.monospacedDigit()) - if let goal = band.goalText { - Text(goal).font(.caption2).foregroundColor(.secondary) - } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(height: 220) - - // Clinical helper / suggestion text, as on the standard report. - VStack(alignment: .leading, spacing: 4) { - Text(String(format: NSLocalizedString("Above range (>%1$@): %2$d%% · goal <25%%", - comment: "TIR above-range summary (1: upper target edge, 2: percent of time)"), - edge(180, 10.0), aboveRange)) - Text(String(format: NSLocalizedString("Below range (<%1$@): %2$d%% · goal <4%%", - comment: "TIR below-range summary (1: lower target edge, 2: percent of time)"), - edge(70, 3.9), belowRange)) - Text(NSLocalizedString("Each 5% increase in range is clinically beneficial.", comment: "TIR suggestion")) - Text(NSLocalizedString("Each 1% time in range ≈ 15 minutes.", comment: "TIR helper")) - } - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } -} - -#if DEBUG -#Preview { - TimeInRangeBar(timeInRange: [.veryLow: 0.04, .low: 0.04, .target: 0.36, .high: 0.25, .veryHigh: 0.31]) - .padding() - .environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) -} -#endif diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index cc81b3cff1..24388d1a5b 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -695,6 +695,91 @@ class LoopDataManagerTests: XCTestCase { try await assertMissingCarbScheduleThrows(.insulinSensitivitySchedule, preview: true) } + func testHistoricalTargetsUseStoredProfilesAndEndedPresets() async throws { + func target(_ low: Double, _ high: Double) -> ClosedRange { + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: low)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: high) + } + let window = DateInterval(start: d(-.hours(3)), end: d(-.minutes(30))) + let store = try await reportingSettingsStore([ + StoredSettings(date: window.start, glucoseTargetRangeSchedule: GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110))], timeZone: .utcTimeZone)), + StoredSettings(date: d(-.hours(2)), glucoseTargetRangeSchedule: GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 120, maxValue: 130))], timeZone: .utcTimeZone)), + ]) + var preset = TemporaryScheduleOverride(context: .custom, + settings: TemporaryPresetSettings(targetRange: target(150, 160), insulinNeedsScaleFactor: 1), + startDate: d(-.minutes(150)), duration: .finite(.hours(2)), + enactTrigger: .local, syncIdentifier: UUID()) + preset.actualEnd = .early(d(-.minutes(90))) + temporaryPresetsManager.presetHistory.recordOverride(preset, at: d(-.minutes(150))) + + let bands = try await loopDataManager.glucoseTargetBands(in: window, settingsStore: store) + XCTAssertEqual(bands, [ + TargetBand(start: window.start, end: d(-.minutes(150)), range: 100...110), + TargetBand(start: d(-.minutes(150)), end: d(-.minutes(90)), range: 150...160), + TargetBand(start: d(-.minutes(90)), end: window.end, range: 120...130), + ]) + } + + func testTargetBandsAreUnknownBeforeTherapyIsConfigured() async throws { + // Today's profile must not fill in a period whose saved settings had no profile. + let store = try await reportingSettingsStore([StoredSettings(date: d(-.hours(4)))]) + let bands = try await loopDataManager.glucoseTargetBands(in: DateInterval(start: d(-.hours(3)), end: now), settingsStore: store) + XCTAssertTrue(bands.isEmpty) + } + + func testReportingSchedulesLeaveUnrecordedHistoryUnknown() async throws { + let window = DateInterval(start: d(-.hours(4)), end: now) + let first = window.start.addingTimeInterval(.hours(1)) + let missing = first.addingTimeInterval(.hours(1)) + let resumed = missing.addingTimeInterval(.hours(1)) + func profile(at date: Date, rate: Double, target: Double) -> StoredSettings { + StoredSettings(date: date, glucoseTargetRangeSchedule: GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: target, maxValue: target + 10))], timeZone: .utcTimeZone), + basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: rate)], timeZone: .utcTimeZone)) + } + let store = try await reportingSettingsStore([ + profile(at: first, rate: 1, target: 100), + StoredSettings(date: missing), + profile(at: resumed, rate: 2, target: 120), + ]) + + let basal = try await LoopHistoryProvider.basalHistory(in: window, from: store) + let schedule = basal.map { LoopHistoryMath.ScheduleValue(start: $0.startDate, end: $0.endDate, rate: $0.value) } + let delivered = LoopHistoryMath.basalSegments(window: window, schedule: schedule, deliveries: [], + programmedRates: schedule, recordsStart: window.start) + XCTAssertEqual(delivered.reduce(0) { $0 + $1.units }, 3, accuracy: 1e-9, + "Only the two recorded hours can supply inferred basal, not the earlier time or missing profile") + XCTAssertNil(LoopHistoryMath.scheduledUnits(basal.map { ScheduledBasalPoint(start: $0.startDate, end: $0.endDate, rate: $0.value) }, over: window)) + let targets = try await loopDataManager.glucoseTargetBands(in: window, settingsStore: store) + XCTAssertEqual(targets, [TargetBand(start: first, end: missing, range: 100...110), + TargetBand(start: resumed, end: window.end, range: 120...130)]) + + // A query starting during a known profile still includes the setting saved before its start. + let inside = DateInterval(start: first.addingTimeInterval(.minutes(30)), end: missing) + let partial = try await LoopHistoryProvider.basalHistory(in: inside, from: store) + XCTAssertEqual(partial.first?.startDate, inside.start) + XCTAssertEqual(partial.reduce(0) { $0 + $1.value * $1.endDate.timeIntervalSince($1.startDate) / 3600 }, 0.5, accuracy: 1e-9) + let before = DateInterval(start: window.start, end: first) + let earlierBasal = try await LoopHistoryProvider.basalHistory(in: before, from: store) + let earlierTargets = try await loopDataManager.glucoseTargetBands(in: before, settingsStore: store) + XCTAssertTrue(earlierBasal.isEmpty) + XCTAssertTrue(earlierTargets.isEmpty) + } + + private func reportingSettingsStore(_ settings: [StoredSettings]) async throws -> SettingsStore { + let store = SettingsStore(store: PersistenceController.mock(), expireAfter: .days(3650)) + for record in settings { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + store.storeSettings(record) { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + } + } + } + return store + } + func testFetchDataWithHighInsulinNeedsPresetMitigation() async throws { var input = try await loopDataManager.fetchData(for: now) XCTAssertEqual(input.targetMgdl.count, 1) diff --git a/LoopTests/Models/LoopHistoryMathTests.swift b/LoopTests/Models/LoopHistoryMathTests.swift new file mode 100644 index 0000000000..901261e514 --- /dev/null +++ b/LoopTests/Models/LoopHistoryMathTests.swift @@ -0,0 +1,448 @@ +// +// LoopHistoryMathTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import Loop + +class LoopHistoryMathTests: XCTestCase { + + private let start = Date(timeIntervalSinceReferenceDate: 800_000_000) + private func at(_ minutes: Double) -> Date { start.addingTimeInterval(minutes * 60) } + + // MARK: Basal + + func testUnrecordedBasalCountsAtTheProgrammedRateOnlyAfterRecordsBegin() { + let window = DateInterval(start: at(0), end: at(240)) + let programmed = [LoopHistoryMath.ScheduleValue(start: at(0), end: at(240), rate: 1.2)] + let temp = LoopHistoryMath.BasalDelivery(start: at(120), end: at(150), rate: 0.4, kind: .temp, automatic: true) + let segments = LoopHistoryMath.basalSegments(window: window, schedule: programmed, deliveries: [temp], + programmedRates: programmed, recordsStart: at(60)) + // Nothing before the first record; 60–120 and 150–240 at 1.2 U/h; 120–150 at the temp rate. + XCTAssertNil(LoopHistoryMath.basalRate(at: at(30), in: segments)) + XCTAssertEqual(LoopHistoryMath.basalRate(at: at(90), in: segments)?.kind, .scheduled) + XCTAssertEqual(LoopHistoryMath.basalRate(at: at(135), in: segments)?.rate, 0.4) + XCTAssertEqual(segments.reduce(0) { $0 + $1.units }, 1.2 * 2.5 + 0.4 * 0.5, accuracy: 1e-9) + } + + func testBasalGapsDoNotInventDelivery() { + let window = DateInterval(start: at(0), end: at(120)) + let schedule = [LoopHistoryMath.ScheduleValue(start: at(-60), end: at(240), rate: 1.0)] + let temp = LoopHistoryMath.BasalDelivery(start: at(60), end: at(90), rate: 2.0, kind: .temp, automatic: true) + let segments = LoopHistoryMath.basalSegments(window: window, schedule: schedule, deliveries: [temp]) + XCTAssertEqual(segments.count, 1) + XCTAssertEqual(segments.reduce(0) { $0 + $1.units }, 1, accuracy: 1e-9) + XCTAssertEqual(segments[0].netUnits, 0.5, accuracy: 1e-9) + } + + func testSuspendDeliversNothingAndLaterRecordWins() { + let window = DateInterval(start: at(0), end: at(60)) + let schedule = [LoopHistoryMath.ScheduleValue(start: at(0), end: at(60), rate: 1.2)] + let deliveries = [ + LoopHistoryMath.BasalDelivery(start: at(0), end: at(60), rate: 3.0, kind: .temp, automatic: true), + LoopHistoryMath.BasalDelivery(start: at(20), end: at(40), rate: 0, kind: .suspended, automatic: false), + ] + let segments = LoopHistoryMath.basalSegments(window: window, schedule: schedule, deliveries: deliveries) + XCTAssertEqual(segments.map(\.kind), [.temp, .suspended, .temp]) + XCTAssertEqual(segments[1].units, 0) + XCTAssertEqual(segments[1].netUnits, -0.4, accuracy: 1e-9) + } + + func testNormalisedBasalPreservesDeliveredQuantityInPartialWindow() throws { + // The pump reports 1.05 U delivered, although the programmed rate implies 1.0625 U. + let dose = DoseEntry(type: .tempBasal, startDate: at(0), endDate: at(30), value: 2.125, + unit: .unitsPerHour, decisionId: nil, deliveredUnits: 1.05, automatic: true) + let delivery = try XCTUnwrap(LoopHistoryMath.BasalDelivery(dose: dose)) + for (end, expected) in [(30.0, 1.05), (15.0, 0.525)] { + let segments = LoopHistoryMath.basalSegments(window: DateInterval(start: at(0), end: at(end)), + schedule: [], deliveries: [delivery]) + XCTAssertEqual(segments.reduce(0) { $0 + $1.units }, expected, accuracy: 1e-9) + } + } + + func testHealthAndLocalPartitionsConserveBasalDelivery() throws { + let sample = HKQuantitySample(type: HKQuantityType(.insulinDelivery), + quantity: HKQuantity(unit: .internationalUnit(), doubleValue: 1), + start: at(0), end: at(60), + metadata: [HKMetadataKeyInsulinDeliveryReason: HKInsulinDeliveryReason.basal.rawValue]) + let left = try XCTUnwrap(BasalSegment(healthSample: sample, in: DateInterval(start: at(-30), end: at(30)))) + let right = try XCTUnwrap(BasalSegment(healthSample: sample, in: DateInterval(start: at(30), end: at(90)))) + XCTAssertEqual(left.units, 0.5, accuracy: 1e-9) + XCTAssertEqual(right.units, 0.5, accuracy: 1e-9, "Include basal beginning before the selected interval") + let dose = DoseEntry(type: .basal, startDate: at(0), endDate: at(60), value: 1, unit: .units, decisionId: nil, deliveredUnits: 1) + let local = LoopHistoryMath.basalSegments(window: DateInterval(start: at(30), end: at(90)), schedule: [], + deliveries: [try XCTUnwrap(LoopHistoryMath.BasalDelivery(dose: dose))]) + XCTAssertEqual(left.units + local.reduce(0) { $0 + $1.units }, 1, accuracy: 1e-9) + XCTAssertNil(BasalSegment(healthSample: sample, in: DateInterval(start: at(60), end: at(90)))) + + } + + func testBolusWindowIsHalfOpenAndPreservesDeliveredUnits() { + let window = DateInterval(start: at(0), end: at(60)) + for (minute, expected) in [(-1.0, false), (0.0, true), (30.0, true), (60.0, false)] { + let dose = DoseEntry(type: .bolus, startDate: at(minute), value: 1, unit: .units, decisionId: nil, deliveredUnits: 0.75) + let event = BolusEvent(dose: dose, in: window) + XCTAssertEqual(event != nil, expected, "Bolus at minute \(minute)") + if expected { XCTAssertEqual(event?.units, 0.75) } + } + } + + // MARK: Glucose + + func testTimeInRangeBandsAndRoundingSumTo100() { + let values: [Double] = [50, 60, 100, 100, 100, 200, 300] + let points = values.enumerated().map { GlucosePoint(date: at(Double($0.offset * 5)), mgdl: $0.element) } + let fractions = LoopHistoryMath.timeInRange(points) + XCTAssertEqual(fractions[.veryLow]!, 1 / 7, accuracy: 1e-9) + XCTAssertEqual(fractions[.low]!, 1 / 7, accuracy: 1e-9) + XCTAssertEqual(fractions[.inRange]!, 3 / 7, accuracy: 1e-9) + XCTAssertEqual(fractions[.high]!, 1 / 7, accuracy: 1e-9) + XCTAssertEqual(fractions[.veryHigh]!, 1 / 7, accuracy: 1e-9) + XCTAssertEqual(LoopHistoryMath.roundedPercentages(fractions).values.reduce(0, +), 100) + } + + func testBandEdgesFollowConsensusThresholds() { + XCTAssertEqual(LoopHistoryMath.RangeBand.band(for: 53.9), .veryLow) + XCTAssertEqual(LoopHistoryMath.RangeBand.band(for: 54), .low) + XCTAssertEqual(LoopHistoryMath.RangeBand.band(for: 70), .inRange) + XCTAssertEqual(LoopHistoryMath.RangeBand.band(for: 180), .inRange) + XCTAssertEqual(LoopHistoryMath.RangeBand.band(for: 181), .high) + XCTAssertEqual(LoopHistoryMath.RangeBand.band(for: 251), .veryHigh) + } + + func testGMIFromMean() { + let points = [GlucosePoint(date: at(0), mgdl: 150), GlucosePoint(date: at(5), mgdl: 150)] + let stats = LoopHistoryMath.glucoseStats(points)! + XCTAssertEqual(stats.gmiPercent, 3.31 + 0.02392 * 150, accuracy: 1e-9) + XCTAssertEqual(stats.gmiMmolMol, (stats.gmiPercent - 2.152) * 10.929, accuracy: 1e-9) + XCTAssertNil(LoopHistoryMath.glucoseStats([points[0]])) + } + + func testCGMCoverageNotedOnlyWhenReadingsStartLate() { + let interval = DateInterval(start: at(0), end: at(14 * 1440)) + let late = [GlucosePoint(date: at(9 * 1440), mgdl: 120), GlucosePoint(date: at(10 * 1440), mgdl: 130)] + XCTAssertEqual(LoopHistoryMath.cgmCoveredDays(late, in: interval, periodDays: 14), 5) + // Within a day of the full period, or a one-day period, says nothing. + XCTAssertNil(LoopHistoryMath.cgmCoveredDays([GlucosePoint(date: at(600), mgdl: 120)], in: interval, periodDays: 14)) + XCTAssertNil(LoopHistoryMath.cgmCoveredDays(late, in: DateInterval(start: at(0), end: at(1440)), periodDays: 1)) + XCTAssertNil(LoopHistoryMath.cgmCoveredDays([], in: interval, periodDays: 14)) + } + + func testGMIIsRepresentativeFromFourteenDaysAtSeventyPercent() { + // "14 d" ending today still counts as 14 days, and exactly 70% passes. + XCTAssertTrue(LoopHistoryMath.gmiIsRepresentative(periodDays: 14, cgmActive: 0.73)) + XCTAssertTrue(LoopHistoryMath.gmiIsRepresentative(periodDays: 14, cgmActive: 0.7)) + XCTAssertTrue(LoopHistoryMath.gmiIsRepresentative(periodDays: 90, cgmActive: 0.95)) + // Each condition on its own is enough to add the caption. + XCTAssertFalse(LoopHistoryMath.gmiIsRepresentative(periodDays: 14, cgmActive: 0.69)) + XCTAssertFalse(LoopHistoryMath.gmiIsRepresentative(periodDays: 7, cgmActive: 1)) + XCTAssertFalse(LoopHistoryMath.gmiIsRepresentative(periodDays: 1, cgmActive: 1)) + } + + func testBandHoursCountOnlyTimeWithReadings() { + // Today until 15:00 with readings 15% of the time: 2.25 h with readings, not 24. + let today = DateInterval(start: at(0), end: at(15 * 60)) + XCTAssertEqual(LoopHistoryMath.cgmHoursPerDay(cgmActive: 0.15, over: today, periodDays: 1), 2.25, accuracy: 1e-9) + // A longer period averages over its days. + let fortnight = DateInterval(start: at(0), end: at(14 * 1440)) + XCTAssertEqual(LoopHistoryMath.cgmHoursPerDay(cgmActive: 0.75, over: fortnight, periodDays: 14), 18, accuracy: 1e-9) + } + + func testHealthBoundaryIsTheLaterOfLocalHistoryAndFirstRecord() { + let localStart = at(0), now = at(90 * 1440) + // Loop began recording 20 days into its 90-day history: Health covers the days before. + XCTAssertEqual(LoopHistoryMath.healthBoundary(localHistoryStart: localStart, firstLocalRecord: at(20 * 1440), now: now), at(20 * 1440)) + // A record older than the local history has been purged up to its start. + XCTAssertEqual(LoopHistoryMath.healthBoundary(localHistoryStart: localStart, firstLocalRecord: at(-60), now: now), localStart) + // No records of a kind: all of it comes from Health. + XCTAssertEqual(LoopHistoryMath.healthBoundary(localHistoryStart: localStart, firstLocalRecord: nil, now: now), now) + } + + func testFirstRecordIgnoresRecordsOutsideTheChunk() { + let chunk = DateInterval(start: at(0), end: at(7 * 1440)) + // A dose still in progress today comes back with an older week's query; it isn't that week's first record. + let inProgress = (start: at(30 * 1440), end: at(30 * 1440 + 30)) + XCTAssertNil(LoopHistoryMath.firstRecord(in: chunk, of: [inProgress])) + // A basal crossing into the chunk keeps its real start, and a bolus counts by its start. + let crossing = (start: at(-60), end: at(60)) + let bolus = (start: at(120), end: at(120)) + XCTAssertEqual(LoopHistoryMath.firstRecord(in: chunk, of: [inProgress, bolus, crossing]), at(-60)) + XCTAssertEqual(LoopHistoryMath.firstRecord(in: chunk, of: [inProgress, bolus]), at(120)) + // A bolus before the chunk, or at its end, is outside it. + XCTAssertNil(LoopHistoryMath.firstRecord(in: chunk, of: [(start: at(-5), end: at(-5)), (start: chunk.end, end: chunk.end)])) + } + + func testLoggedInjectionsAreManualAndMissingFlagsUnknown() { + let window = DateInterval(start: at(0), end: at(60)) + for (automatic, entered, manual, auto, unknown) in [ + (nil, true, 1.0, 0.0, 0.0), + (true, true, 1.0, 0.0, 0.0), + (false, false, 1.0, 0.0, 0.0), + (true, false, 0.0, 1.0, 0.0), + (nil, false, 0.0, 0.0, 1.0), + ] as [(Bool?, Bool, Double, Double, Double)] { + let dose = DoseEntry(type: .bolus, startDate: at(5), value: 1, unit: .units, decisionId: nil, + automatic: automatic, manuallyEntered: entered) + guard let event = BolusEvent(dose: dose, in: window) else { return XCTFail("Dose must be in the window") } + let split = InsulinSplit(basal: 0, boluses: [event]) + XCTAssertEqual(split.manualBolus, manual) + XCTAssertEqual(split.automaticBolus, auto) + XCTAssertEqual(split.unclassifiedBolus, unknown) + } + } + + func testHealthAndLocalPartsMeetWithoutOverlap() { + let window = DateInterval(start: at(0), end: at(1440)) + let straddling = LoopHistoryMath.split(window, at: at(600)) + XCTAssertEqual(straddling.health, DateInterval(start: at(0), end: at(600))) + XCTAssertEqual(straddling.local, DateInterval(start: at(600), end: at(1440))) + let allLocal = LoopHistoryMath.split(window, at: at(-60)) + XCTAssertNil(allLocal.health) + XCTAssertEqual(allLocal.local, window) + let allHealth = LoopHistoryMath.split(window, at: at(1440)) + XCTAssertEqual(allHealth.health, window) + XCTAssertNil(allHealth.local) + } + + func testBolusPartsNeverTouchAndKeepTheirHeight() { + let day = LoopHistoryMath.DayTotal(day: at(0), split: InsulinSplit(basal: 20, manualBolus: 10, automaticBolus: 0.2, unclassifiedBolus: 2)) + let parts = InsulinDailyTotalsView.bolusParts(day, gap: 0.5) + XCTAssertEqual(parts.map(\.automatic), [false, true]) + // Manual and unknown together, a gap above basal, keeping its top. + XCTAssertEqual(parts[0].start, 20.5, accuracy: 1e-9) + XCTAssertEqual(parts[0].end, 32, accuracy: 1e-9) + // Automatic is shorter than two gaps, so it moves up a gap and keeps its full height. + XCTAssertEqual(parts[1].start, 32.5, accuracy: 1e-9) + XCTAssertEqual(parts[1].end - parts[1].start, 0.2, accuracy: 1e-9) + XCTAssertTrue(InsulinDailyTotalsView.bolusParts(LoopHistoryMath.DayTotal(day: at(0), basal: 20, bolus: 0), gap: 0.5).isEmpty) + } + + func testScheduledUnitsNeedTheWholeSpanCovered() { + let points = [ScheduledBasalPoint(start: at(0), end: at(120), rate: 1.0), ScheduledBasalPoint(start: at(120), end: at(240), rate: 0.5)] + XCTAssertEqual(try XCTUnwrap(LoopHistoryMath.scheduledUnits(points, over: DateInterval(start: at(60), end: at(180)))), 1.5, accuracy: 1e-9) + XCTAssertNil(LoopHistoryMath.scheduledUnits(points, over: DateInterval(start: at(60), end: at(360)))) + } + + func testPercentileInterpolates() { + XCTAssertEqual(LoopHistoryMath.percentile([1, 2, 3, 4, 5], 0.5), 3) + XCTAssertEqual(LoopHistoryMath.percentile([1, 2, 3, 4], 0.5), 2.5) + XCTAssertEqual(LoopHistoryMath.percentile([10, 20], 0.25), 12.5) + } + + // MARK: Loop modes + + func testLoopModesStartAtFirstModeRecord() { + let interval = DateInterval(start: at(0), end: at(60)) + let runs = stride(from: 0.0, through: 60, by: 5).map { LoopHistoryMath.LoopRun(date: at($0), succeeded: true) } + let spans = LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, + modeChanges: [.init(date: at(30), closedLoop: true)], coverageStart: at(0)) + XCTAssertEqual(spans.first?.start, at(30), "Before the first record the mode is unknown and left out") + XCTAssertEqual(LoopHistoryMath.duration(of: .closed, in: spans), 30 * 60, accuracy: 1) + + XCTAssertTrue(LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, modeChanges: [], coverageStart: at(0)).isEmpty) + } + + func testLoopModesClassifyOpenAndLimited() { + let interval = DateInterval(start: at(0), end: at(90)) + // Runs succeed until 20 min, then fail with an error, then stop. + var runs = stride(from: 0.0, through: 20, by: 5).map { LoopHistoryMath.LoopRun(date: at($0), succeeded: true) } + runs.append(.init(date: at(25), succeeded: false)) + let changes: [LoopHistoryMath.ModeChange] = [.init(date: at(0), closedLoop: true), .init(date: at(60), closedLoop: false)] + + let spans = LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, modeChanges: changes, coverageStart: at(0)) + XCTAssertEqual(spans.map(\.mode), [.closed, .limited, .open]) + XCTAssertEqual(spans[1].start, at(35), "Limited starts once the last good run is over 15 minutes old") + XCTAssertEqual(spans[2].start, at(55), "The slot ending at the switch-off counts as open") + } + + func testManualTempBasalCountsAsOpenLoop() { + let interval = DateInterval(start: at(0), end: at(60)) + // Closed loop stays on; from 20 min a manual temp basal holds every run, until it ends at 40 min. + let runs = stride(from: 0.0, through: 60, by: 5).map { minute in + LoopHistoryMath.LoopRun(date: at(minute), succeeded: !(20...40).contains(minute), manualTempBasal: (20...40).contains(minute)) + } + let spans = LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, modeChanges: [.init(date: at(0), closedLoop: true)], + coverageStart: at(0)) + XCTAssertEqual(spans.map(\.mode), [.closed, .open, .closed]) + XCTAssertEqual(spans[1].start, at(15), "The slot holding the first held run is open") + XCTAssertEqual(spans[2].start, at(40), "Closed again with the first good run after it") + } + + func testManualTempBasalStaysOpenWhenALaterRunFailsForAnotherReason() { + let interval = DateInterval(start: at(0), end: at(60)) + // A manual temp basal runs 20–40 min. The run at 25 is held by it; from 30 glucose is stale, so later runs fail + // on that before Loop checks for the manual temp basal. + let runs = stride(from: 0.0, through: 60, by: 5).map { minute in + LoopHistoryMath.LoopRun(date: at(minute), succeeded: minute < 20 || minute > 40, manualTempBasal: minute == 25) + } + let spans = LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, modeChanges: [.init(date: at(0), closedLoop: true)], + coverageStart: at(0), manualTempBasals: [DateInterval(start: at(20), end: at(40))]) + XCTAssertEqual(spans.map(\.mode), [.closed, .open, .closed]) + XCTAssertEqual(spans[1].start, at(20)) + XCTAssertEqual(spans[1].end, at(40), "Open for the whole manual temp basal, not just after the run it held") + } + + func testRecordedCancellationEndsManualTempBasalBeforeTheRunFallback() { + let interval = DateInterval(start: at(0), end: at(30)) + // A good run at 0; a manual temp basal from 3 holds the run at 5; it's cancelled at 12, and no runs follow. + let runs = [LoopHistoryMath.LoopRun(date: at(0), succeeded: true), .init(date: at(5), succeeded: false, manualTempBasal: true)] + let changes: [LoopHistoryMath.ModeChange] = [.init(date: at(0), closedLoop: true)] + let spans = LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, modeChanges: changes, + coverageStart: at(0), manualTempBasals: [DateInterval(start: at(3), end: at(12))]) + XCTAssertEqual(spans.map(\.mode), [.closed, .open, .closed, .limited]) + XCTAssertEqual(spans[1].start, at(5)) + XCTAssertEqual(spans[1].end, at(10), "Open while the recorded temp basal runs, not for 15 minutes after the run it held") + XCTAssertEqual(spans[3].start, at(15), "Limited once the last good run is over 15 minutes old") + + // A period starting after the cancellation still needs the preceding dose record. This can happen at + // midnight, while the last held run is recent enough for the fallback to apply. + let nextPeriod = LoopHistoryMath.loopModeSpans(interval: DateInterval(start: at(15), end: at(30)), runs: runs, + modeChanges: changes, coverageStart: at(0), + manualTempBasals: [DateInterval(start: at(3), end: at(12))]) + XCTAssertEqual(nextPeriod.map(\.mode), [.limited]) + + // With no dose record, the held run alone keeps the loop open for 15 minutes. + let unrecorded = LoopHistoryMath.loopModeSpans(interval: interval, runs: runs, modeChanges: changes, coverageStart: at(0)) + XCTAssertEqual(unrecorded.map(\.mode), [.open, .limited]) + XCTAssertEqual(unrecorded[1].start, at(20)) + } + + // MARK: Calendar + + func testDaysAndQuarterMarksOnClockChangeDay() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "Europe/London")) + // 25 Oct 2026: clocks go back an hour, so the day is 25 hours long. + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 10, day: 25))) + let next = try XCTUnwrap(calendar.date(byAdding: .day, value: 1, to: day)) + XCTAssertEqual(next.timeIntervalSince(day), 25 * 3600) + + let marks = DayAxis.quarterDayMarks(in: DateInterval(start: day, end: next), calendar: calendar) + XCTAssertEqual(marks.map { calendar.component(.hour, from: $0) }, [0, 6, 12, 18, 0]) + + let days = LoopHistoryMath.days(in: DateInterval(start: day, end: next.addingTimeInterval(3600)), calendar: calendar) + XCTAssertEqual(days.count, 2) + XCTAssertEqual(days[0].duration, 25 * 3600) + } + + func testWeeklyAveragesDays() { + let monday = Date(timeIntervalSinceReferenceDate: 815_270_400) // 9436 days after Monday 1 Jan 2001, so a Monday at midnight UTC + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let totals = (0..<14).map { LoopHistoryMath.DayTotal(day: monday.addingTimeInterval(Double($0) * 86400), basal: 10, bolus: Double($0)) } + let weeks = LoopHistoryMath.weekly(totals, calendar: calendar) + XCTAssertEqual(weeks.count, 2) + XCTAssertEqual(weeks[0].bolus, 3, accuracy: 1e-9) + XCTAssertEqual(weeks[1].bolus, 10, accuracy: 1e-9) + } + + func testDailyInsulinAverageLeavesOutTheDayInProgress() { + let day0 = Date(timeIntervalSinceReferenceDate: 815_270_400) + let complete = (0..<3).map { LoopHistoryMath.DayTotal(day: day0.addingTimeInterval(Double($0) * 86400), basal: 10, bolus: 20) } + let today = LoopHistoryMath.DayTotal(day: day0.addingTimeInterval(3 * 86400), basal: 3, bolus: 2, isComplete: false) + XCTAssertEqual(LoopHistoryMath.averageDailyInsulin(complete + [today]), 30, accuracy: 1e-9) + XCTAssertEqual(LoopHistoryMath.averagingDays(complete + [today]).count, 3) + // With nothing complete, the day in progress is all there is to show. + XCTAssertEqual(LoopHistoryMath.averageDailyInsulin([today]), 5, accuracy: 1e-9) + } + + func testInsulinSplitSeparatesManualAutomaticAndUnclassifiedBolus() { + let boluses = [ + BolusEvent(id: "m", date: at(0), units: 4, automatic: false, manuallyEntered: false), + BolusEvent(id: "a", date: at(5), units: 0.5, automatic: true, manuallyEntered: false), + BolusEvent(id: "u", date: at(10), units: 1, automatic: false, manuallyEntered: false, automaticKnown: false), + ] + let split = InsulinSplit(basal: 14.5, boluses: Array(boluses.prefix(2))) + XCTAssertEqual(split.manualBolus, 4, accuracy: 1e-9) + XCTAssertEqual(split.automaticBolus, 0.5, accuracy: 1e-9) + XCTAssertEqual(split.total, 19, accuracy: 1e-9) + XCTAssertTrue(split.isClassified) + // Shares round independently but always add up to 100. + XCTAssertEqual(split.basalPercent + split.bolusPercent, 100) + XCTAssertEqual(split.bolusPercent, 24) + + // A bolus that doesn't say who gave it still counts, but hides the manual and automatic detail. + let health = InsulinSplit(basal: 14.5, boluses: boluses) + XCTAssertEqual(health.bolus, 5.5, accuracy: 1e-9) + XCTAssertFalse(health.isClassified) + + let average = InsulinSplit.average([split, InsulinSplit(basal: 10.5, manualBolus: 2, automaticBolus: 1.5)]) + XCTAssertEqual(average.basal, 12.5, accuracy: 1e-9) + XCTAssertEqual(average.manualBolus, 3, accuracy: 1e-9) + XCTAssertEqual(average.automaticBolus, 1, accuracy: 1e-9) + } + + func testWeeklyInsulinUsesTheSummaryAveragingDays() { + let monday = Date(timeIntervalSinceReferenceDate: 815_270_400) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let totals = [ + LoopHistoryMath.DayTotal(day: monday, basal: 0, bolus: 0), + LoopHistoryMath.DayTotal(day: monday.addingTimeInterval(86400), basal: 10, bolus: 20), + LoopHistoryMath.DayTotal(day: monday.addingTimeInterval(2 * 86400), basal: 10, bolus: 20), + LoopHistoryMath.DayTotal(day: monday.addingTimeInterval(3 * 86400), basal: 1, bolus: 2, isComplete: false), + ] + let weeks = LoopHistoryMath.weekly(totals, calendar: calendar) + XCTAssertEqual(weeks.count, 1) + XCTAssertEqual(weeks[0].basal, 10, accuracy: 1e-9) + XCTAssertEqual(weeks[0].bolus, 20, accuracy: 1e-9) + + // A current week with no complete day must still show the data it has. + let currentWeek = LoopHistoryMath.weekly([totals[3]], calendar: calendar) + XCTAssertEqual(currentWeek.first?.total, 3) + } + + func testGapMessageForADayWithoutReadings() { + let start = Date(timeIntervalSinceReferenceDate: 815_270_400) + let day = DateInterval(start: start, duration: 86400) + let empty = DayAxis.gaps(in: [], window: day, until: day.end.addingTimeInterval(3600)) + XCTAssertEqual(DayAxis.gapMessage(empty, window: day, until: day.end.addingTimeInterval(3600)), "No CGM data on this day") + let morning = start.addingTimeInterval(9 * 3600) + XCTAssertEqual(DayAxis.gapMessage(DayAxis.gaps(in: [], window: day, until: morning), window: day, until: morning), "No CGM data yet today") + let reading = [GlucosePoint(date: start.addingTimeInterval(12 * 3600), mgdl: 120)] + let partial = DayAxis.gaps(in: reading, window: day, until: day.end) + XCTAssertTrue(DayAxis.gapMessage(partial, window: day, until: day.end)?.hasSuffix("and 1 shorter gap") ?? false) + } + + // MARK: Formatting + + func testRangeFormatting() throws { + let calendar = Calendar.current + let a = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 10, day: 12, hour: 12))) + let b = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 10, day: 25, hour: 12))) + let c = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28, hour: 12))) + // Ranges and durations come from the system's formatters, so they follow the user's locale; + // the test host runs in en_US. + XCTAssertEqual(LoopFormat.range(b, b), LoopFormat.day(b)) + let sameMonth = LoopFormat.range(a, b) + XCTAssertTrue(sameMonth.contains("12") && sameMonth.contains("25")) + XCTAssertEqual(sameMonth.components(separatedBy: "2026").count, 2, "the year appears once") + let twoMonths = LoopFormat.range(c, b) + XCTAssertTrue(twoMonths.contains("Sep") && twoMonths.contains("Oct")) + XCTAssertEqual(LoopFormat.duration(13 * 86400 + 11 * 3600), "13d 11h") + XCTAssertEqual(LoopFormat.duration(9 * 3600 + 45 * 60), "9h 45m") + XCTAssertEqual(LoopFormat.number(7.25, decimals: 1), 7.25.formatted(.number.precision(.fractionLength(1)).grouping(.never))) + XCTAssertEqual(LoopFormat.signed(-0.4), "−0.40") + XCTAssertEqual(LoopFormat.signed(0.0001), "+0.00") + } + + // MARK: Day history + + func testDayHistoryClippedToNowDropsLaterEventsAndCutsBasal() { + var history = DayHistory(window: DateInterval(start: at(0), end: at(1440))) + history.glucose = [GlucosePoint(date: at(10), mgdl: 100), GlucosePoint(date: at(700), mgdl: 120)] + history.boluses = [BolusEvent(id: "a", date: at(30), units: 1, automatic: false, manuallyEntered: false), + BolusEvent(id: "b", date: at(650), units: 2, automatic: true, manuallyEntered: false)] + history.basal = [BasalSegment(start: at(0), end: at(720), rate: 1, scheduledRate: 1, kind: .scheduled, automatic: false)] + let clipped = history.clipped(to: DateInterval(start: at(0), end: at(600))) + XCTAssertEqual(clipped.window.end, at(600)) + XCTAssertEqual(clipped.glucose.count, 1) + XCTAssertEqual(clipped.boluses.map(\.id), ["a"]) + XCTAssertEqual(clipped.basalUnits, 10, accuracy: 1e-9) + } +} diff --git a/README.md b/README.md index 40506a4c42..099d1d64dc 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ Please understand that this project: Presets screen Live Activity on the Lock Screen -Statistics with glucose metrics and time in range -Daily insulin totals on the Statistics screen Choosing a CGM Apple Watch app @@ -34,7 +32,7 @@ Please understand that this project: - **Automated insulin delivery.** Loop forecasts glucose several hours ahead and adjusts delivery with each new glucose reading, using either temporary basal rates or automatic boluses. - **Meal and correction boluses** with a recommended amount and a forecast of where glucose is heading. Carb entries can include food type, absorption time and saved favorite foods. - **Presets** that temporarily adjust insulin needs and correction range: pre-meal, your own custom presets, and built-in activity presets for walking, jogging, biking and strength training (which can be hidden). Presets can be scheduled, and in-app training explains how to use them. -- **Statistics**: GMI, average glucose, variability and time in range, an ambulatory glucose profile, and daily insulin totals split into basal, automatic bolus and manual bolus. +- **Trends**: time in range, GMI, average glucose and variability, a daily glucose pattern, insulin per day split into basal, manual bolus and automatic bolus, and time in closed loop, for one day or for 7 to 90 days. - **Live Activity** on the Lock Screen, in the Dynamic Island and on CarPlay. - **Apple Watch app** with glucose and forecast, carb entry, bolus and presets. - **Alerts** for glucose and device conditions, with urgent alerts that sound even when the phone is silenced or in a Focus.