Showing 3 changed files with 108 additions and 20 deletions
+1 -0
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -576,6 +576,7 @@ rows exist".
576 576
 | 2026-06-03 | `5fafcdd` | Expand the HealthKit type registry for full-dataset discovery while keeping the original 15-type profile as the tested default. | Triggered by the decision that import/storage cannot be considered complete based only on the restricted v1 dataset. Expected signal: Settings/authorization can expose a much broader quantity/category/workout catalog, unsupported types are explicit, and real-device coverage reports can measure full authorized backup volume. |
577 577
 | 2026-06-03 | committed | Add explicit capture profile controls for full-dataset discovery. | A real-device report after registry expansion still showed `Types: 15/15 processed` and the old monitored type-set hash because `selectedTypeIDs` persisted the v1 core profile in `UserDefaults`. Settings now exposes `Select All Available Types`, `Select Core Profile`, and selected/available counts so the next real-device run can deliberately switch from the v1 sample set to the expanded supported registry. |
578 578
 | 2026-06-03 | pending | Migrate legacy core-profile selections to full available capture by default. | A follow-up real-device report still showed the old `4907...` monitored type-set hash and `Types: 15/15 processed`, proving the running app still used the old persisted selected type set. New installs and pre-profile settings that exactly match the old core profile now migrate to `All available`; only an explicit `Select Core Profile` action persists the core subset. Settings also shows the active profile label (`All available`, `Core`, or `Custom`) for quick verification before capture. |
579
+| 2026-06-03 | pending | Harden quantity unit conversion for full-profile imports. | The first 127-metric run crashed while archiving `HKQuantityTypeIdentifierDietaryWater`: the previous fallback converted unknown quantities with `.count()`, and HealthKit raised `NSInvalidArgumentException` for incompatible `mL` to `count`. The archive now maps known extended units, stores quantity rows with nil numeric/unit when a future type is unmapped, and removes the unsafe display fallback. Next run should pass Water and reveal any remaining type-specific unit gaps. |
579 580
 
580 581
 ## Current Diagnosis
581 582
 
+2 -2
HealthProbe/Services/HealthKitService.swift
@@ -1789,7 +1789,7 @@ final class HealthKitService {
1789 1789
         return nil
1790 1790
     }
1791 1791
 
1792
-    private static func quantityDisplayValue(for sample: HKQuantitySample) -> String {
1792
+    private static func quantityDisplayValue(for sample: HKQuantitySample) -> String? {
1793 1793
         let identifier = sample.quantityType.identifier
1794 1794
         switch identifier {
1795 1795
         case HKQuantityTypeIdentifier.stepCount.rawValue:
@@ -1819,7 +1819,7 @@ final class HealthKitService {
1819 1819
                 .unitDivided(by: .minute())
1820 1820
             return "\(format(sample.quantity.doubleValue(for: unit), maximumFractionDigits: 1)) mL/kg/min"
1821 1821
         default:
1822
-            return "\(format(sample.quantity.doubleValue(for: .count()), maximumFractionDigits: 2))"
1822
+            return nil
1823 1823
         }
1824 1824
     }
1825 1825
 
+105 -18
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -3784,33 +3784,120 @@ private struct ArchiveSampleRow {
3784 3784
         String(format: "%.6f", date.timeIntervalSince1970)
3785 3785
     }
3786 3786
 
3787
-    nonisolated private static func quantityPayload(_ sample: HKSample) -> (kind: String, value: Double, unit: String)? {
3787
+    nonisolated private static func quantityPayload(_ sample: HKSample) -> (kind: String, value: Double?, unit: String?)? {
3788 3788
         guard let sample = sample as? HKQuantitySample else { return nil }
3789 3789
         let identifier = sample.quantityType.identifier
3790
+        guard let storageUnit = quantityStorageUnit(for: identifier) else {
3791
+            return ("quantity", nil, nil)
3792
+        }
3793
+        return ("quantity", sample.quantity.doubleValue(for: storageUnit.unit), storageUnit.label)
3794
+    }
3795
+
3796
+    nonisolated private static func quantityStorageUnit(for identifier: String) -> (unit: HKUnit, label: String)? {
3790 3797
         switch identifier {
3791 3798
         case HKQuantityTypeIdentifier.heartRate.rawValue,
3792
-             HKQuantityTypeIdentifier.restingHeartRate.rawValue:
3793
-            return ("quantity", sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), "count/min")
3799
+             HKQuantityTypeIdentifier.restingHeartRate.rawValue,
3800
+             "HKQuantityTypeIdentifierWalkingHeartRateAverage":
3801
+            return (HKUnit.count().unitDivided(by: .minute()), "count/min")
3794 3802
         case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
3795
-            return ("quantity", sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), "count/min")
3796
-        case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
3797
-            return ("quantity", sample.quantity.doubleValue(for: .kilocalorie()), "kcal")
3798
-        case HKQuantityTypeIdentifier.distanceWalkingRunning.rawValue:
3799
-            return ("quantity", sample.quantity.doubleValue(for: .meter()), "m")
3800
-        case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
3801
-            return ("quantity", sample.quantity.doubleValue(for: .minute()), "min")
3803
+            return (HKUnit.count().unitDivided(by: .minute()), "count/min")
3804
+        case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue,
3805
+             "HKQuantityTypeIdentifierBasalEnergyBurned",
3806
+             "HKQuantityTypeIdentifierDietaryEnergyConsumed":
3807
+            return (.kilocalorie(), "kcal")
3808
+        case HKQuantityTypeIdentifier.distanceWalkingRunning.rawValue,
3809
+             "HKQuantityTypeIdentifierDistanceCycling",
3810
+             "HKQuantityTypeIdentifierDistanceSwimming",
3811
+             "HKQuantityTypeIdentifierDistanceDownhillSnowSports",
3812
+             "HKQuantityTypeIdentifierDistanceWheelchair",
3813
+             "HKQuantityTypeIdentifierHeight",
3814
+             "HKQuantityTypeIdentifierWaistCircumference",
3815
+             "HKQuantityTypeIdentifierWalkingStepLength",
3816
+             "HKQuantityTypeIdentifierSixMinuteWalkTestDistance":
3817
+            return (.meter(), "m")
3818
+        case "HKQuantityTypeIdentifierWalkingSpeed",
3819
+             "HKQuantityTypeIdentifierStairAscentSpeed",
3820
+             "HKQuantityTypeIdentifierStairDescentSpeed":
3821
+            return (.meter().unitDivided(by: .second()), "m/s")
3822
+        case HKQuantityTypeIdentifier.appleExerciseTime.rawValue,
3823
+             "HKQuantityTypeIdentifierAppleMoveTime",
3824
+             "HKQuantityTypeIdentifierAppleStandTime":
3825
+            return (.minute(), "min")
3802 3826
         case HKQuantityTypeIdentifier.environmentalAudioExposure.rawValue,
3803 3827
              HKQuantityTypeIdentifier.headphoneAudioExposure.rawValue:
3804
-            return ("quantity", sample.quantity.doubleValue(for: .decibelAWeightedSoundPressureLevel()), "dBASPL")
3805
-        case HKQuantityTypeIdentifier.bodyMass.rawValue:
3806
-            return ("quantity", sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo)), "kg")
3828
+            return (.decibelAWeightedSoundPressureLevel(), "dBASPL")
3829
+        case HKQuantityTypeIdentifier.bodyMass.rawValue,
3830
+             "HKQuantityTypeIdentifierLeanBodyMass":
3831
+            return (HKUnit.gramUnit(with: .kilo), "kg")
3807 3832
         case HKQuantityTypeIdentifier.vo2Max.rawValue:
3808
-            let unit = HKUnit.literUnit(with: .milli)
3809
-                .unitDivided(by: HKUnit.gramUnit(with: .kilo))
3810
-                .unitDivided(by: .minute())
3811
-            return ("quantity", sample.quantity.doubleValue(for: unit), "mL/kg/min")
3833
+            return (
3834
+                HKUnit.literUnit(with: .milli)
3835
+                    .unitDivided(by: HKUnit.gramUnit(with: .kilo))
3836
+                    .unitDivided(by: .minute()),
3837
+                "mL/kg/min"
3838
+            )
3839
+        case HKQuantityTypeIdentifier.stepCount.rawValue,
3840
+             "HKQuantityTypeIdentifierFlightsClimbed",
3841
+             "HKQuantityTypeIdentifierSwimmingStrokeCount",
3842
+             "HKQuantityTypeIdentifierPushCount",
3843
+             "HKQuantityTypeIdentifierInhalerUsage",
3844
+             "HKQuantityTypeIdentifierBodyMassIndex":
3845
+            return (.count(), "count")
3846
+        case "HKQuantityTypeIdentifierHeartRateVariabilitySDNN":
3847
+            return (HKUnit.secondUnit(with: .milli), "ms")
3848
+        case "HKQuantityTypeIdentifierAtrialFibrillationBurden",
3849
+             "HKQuantityTypeIdentifierOxygenSaturation",
3850
+             "HKQuantityTypeIdentifierBodyFatPercentage",
3851
+             "HKQuantityTypeIdentifierPeripheralPerfusionIndex",
3852
+             "HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
3853
+             "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
3854
+             "HKQuantityTypeIdentifierAppleWalkingSteadiness":
3855
+            return (.percent(), "%")
3856
+        case "HKQuantityTypeIdentifierForcedVitalCapacity",
3857
+             "HKQuantityTypeIdentifierForcedExpiratoryVolume1":
3858
+            return (.liter(), "L")
3859
+        case "HKQuantityTypeIdentifierPeakExpiratoryFlowRate":
3860
+            return (.liter().unitDivided(by: .minute()), "L/min")
3861
+        case "HKQuantityTypeIdentifierBodyTemperature",
3862
+             "HKQuantityTypeIdentifierBasalBodyTemperature":
3863
+            return (.degreeCelsius(), "degC")
3864
+        case "HKQuantityTypeIdentifierBloodPressureSystolic",
3865
+             "HKQuantityTypeIdentifierBloodPressureDiastolic":
3866
+            return (.millimeterOfMercury(), "mmHg")
3867
+        case "HKQuantityTypeIdentifierBloodGlucose":
3868
+            return (HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)), "mg/dL")
3869
+        case "HKQuantityTypeIdentifierInsulinDelivery":
3870
+            return (.internationalUnit(), "IU")
3871
+        case "HKQuantityTypeIdentifierElectrodermalActivity":
3872
+            return (HKUnit.siemenUnit(with: .micro), "uS")
3873
+        case "HKQuantityTypeIdentifierDietaryWater":
3874
+            return (HKUnit.literUnit(with: .milli), "mL")
3875
+        case "HKQuantityTypeIdentifierDietaryProtein",
3876
+             "HKQuantityTypeIdentifierDietaryCarbohydrates",
3877
+             "HKQuantityTypeIdentifierDietaryFiber",
3878
+             "HKQuantityTypeIdentifierDietarySugar",
3879
+             "HKQuantityTypeIdentifierDietaryFatTotal",
3880
+             "HKQuantityTypeIdentifierDietaryFatSaturated",
3881
+             "HKQuantityTypeIdentifierDietaryFatMonounsaturated",
3882
+             "HKQuantityTypeIdentifierDietaryFatPolyunsaturated",
3883
+             "HKQuantityTypeIdentifierDietaryCholesterol",
3884
+             "HKQuantityTypeIdentifierDietarySodium",
3885
+             "HKQuantityTypeIdentifierDietaryPotassium",
3886
+             "HKQuantityTypeIdentifierDietaryCalcium",
3887
+             "HKQuantityTypeIdentifierDietaryIron",
3888
+             "HKQuantityTypeIdentifierDietaryMagnesium",
3889
+             "HKQuantityTypeIdentifierDietaryZinc",
3890
+             "HKQuantityTypeIdentifierDietaryVitaminA",
3891
+             "HKQuantityTypeIdentifierDietaryVitaminB6",
3892
+             "HKQuantityTypeIdentifierDietaryVitaminB12",
3893
+             "HKQuantityTypeIdentifierDietaryVitaminC",
3894
+             "HKQuantityTypeIdentifierDietaryVitaminD",
3895
+             "HKQuantityTypeIdentifierDietaryVitaminE",
3896
+             "HKQuantityTypeIdentifierDietaryVitaminK",
3897
+             "HKQuantityTypeIdentifierDietaryCaffeine":
3898
+            return (.gram(), "g")
3812 3899
         default:
3813
-            return ("quantity", sample.quantity.doubleValue(for: .count()), "count")
3900
+            return nil
3814 3901
         }
3815 3902
     }
3816 3903