Showing 5 changed files with 602 additions and 189 deletions
+53 -7
USB Meter/Model/AppData.swift
@@ -58,6 +58,8 @@ final class AppData : ObservableObject {
58 58
     private var chargeInsightsStore: ChargeInsightsStore?
59 59
     private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
60 60
     private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
61
+    private var meterSummariesCache: (version: Int, summaries: [MeterSummary])?
62
+    private var meterSummariesVersion: Int = 0
61 63
 
62 64
     init() {
63 65
         bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
@@ -66,7 +68,9 @@ final class AppData : ObservableObject {
66 68
         meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
67 69
             .receive(on: DispatchQueue.main)
68 70
             .sink { [weak self] _ in
71
+                self?.invalidateMeterSummaries()
69 72
                 self?.refreshMeterMetadata()
73
+                self?.scheduleObjectWillChange()
70 74
             }
71 75
         meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
72 76
             .receive(on: DispatchQueue.main)
@@ -84,7 +88,11 @@ final class AppData : ObservableObject {
84 88
 
85 89
     @Published var enableRecordFeature: Bool = true
86 90
 
87
-    @Published var meters: [UUID:Meter] = [UUID:Meter]()
91
+    @Published var meters: [UUID:Meter] = [UUID:Meter]() {
92
+        didSet {
93
+            invalidateMeterSummaries()
94
+        }
95
+    }
88 96
     @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
89 97
     @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
90 98
 
@@ -268,10 +276,12 @@ final class AppData : ObservableObject {
268 276
     }
269 277
 
270 278
     func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
271
-        if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
279
+        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
280
+
281
+        if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
272 282
             return cachedSummary
273 283
         }
274
-        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
284
+        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
275 285
     }
276 286
 
277 287
     func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
@@ -516,14 +526,19 @@ final class AppData : ObservableObject {
516 526
             startsFromFlatBattery: startsFromFlatBattery
517 527
         ) ?? false
518 528
         if didSave {
519
-            reloadChargedDevices()
520 529
             meter.resetChargeRecordGraph()
521
-            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
530
+            let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
531
+                forMeterMACAddress: meter.btSerial.macAddress.description
532
+            )
533
+            if let activeSession,
522 534
                meter.supportsRecordingThreshold,
523 535
                activeSession.stopThresholdAmps > 0 {
524 536
                 meter.recordingTreshold = activeSession.stopThresholdAmps
525 537
             }
526
-            restoreChargeMonitoringStateIfNeeded(for: meter)
538
+            if let activeSession {
539
+                meter.restoreChargeMonitoringIfNeeded(from: activeSession)
540
+            }
541
+            reloadChargedDevices()
527 542
         }
528 543
         return didSave
529 544
     }
@@ -698,6 +713,19 @@ final class AppData : ObservableObject {
698 713
         return didDelete
699 714
     }
700 715
 
716
+    @discardableResult
717
+    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
718
+        let didSave = chargeInsightsStore?.setSessionTrim(
719
+            sessionID: sessionID,
720
+            start: start,
721
+            end: end
722
+        ) ?? false
723
+        if didSave {
724
+            reloadChargedDevices()
725
+        }
726
+        return didSave
727
+    }
728
+
701 729
     @discardableResult
702 730
     func flushChargeInsights() -> Bool {
703 731
         let didFlushObservations = flushAllPendingChargeObservations()
@@ -837,11 +865,15 @@ final class AppData : ObservableObject {
837 865
     }
838 866
 
839 867
     var meterSummaries: [MeterSummary] {
868
+        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
869
+            return meterSummariesCache.summaries
870
+        }
871
+
840 872
         let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
841 873
         let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
842 874
         let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
843 875
 
844
-        return macAddresses.map { macAddress in
876
+        let summaries = macAddresses.map { macAddress in
845 877
             let liveMeter = liveMetersByMAC[macAddress]
846 878
             let record = recordsByMAC[macAddress]
847 879
 
@@ -868,6 +900,9 @@ final class AppData : ObservableObject {
868 900
             }
869 901
             return lhs.macAddress < rhs.macAddress
870 902
         }
903
+
904
+        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
905
+        return summaries
871 906
     }
872 907
 
873 908
     private func scheduleObjectWillChange() {
@@ -876,6 +911,11 @@ final class AppData : ObservableObject {
876 911
         }
877 912
     }
878 913
 
914
+    private func invalidateMeterSummaries() {
915
+        meterSummariesVersion += 1
916
+        meterSummariesCache = nil
917
+    }
918
+
879 919
     private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
880 920
         pendingChargedDevicesReloadWorkItem?.cancel()
881 921
 
@@ -1168,6 +1208,9 @@ final class AppData : ObservableObject {
1168 1208
 
1169 1209
     private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1170 1210
         let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1211
+        guard session.isTrimmed == false else {
1212
+            return storedEnergyWh
1213
+        }
1171 1214
         guard session.status.isOpen else {
1172 1215
             return storedEnergyWh
1173 1216
         }
@@ -1185,6 +1228,9 @@ final class AppData : ObservableObject {
1185 1228
 
1186 1229
     private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1187 1230
         let storedChargeAh = session.measuredChargeAh
1231
+        guard session.isTrimmed == false else {
1232
+            return storedChargeAh
1233
+        }
1188 1234
         guard session.status.isOpen else {
1189 1235
             return storedChargeAh
1190 1236
         }
+175 -12
USB Meter/Model/ChargeInsightsStore.swift
@@ -30,7 +30,7 @@ final class ChargeInsightsStore {
30 30
         }
31 31
     }
32 32
 
33
-    private static let persistedSamplesPerHour = 300
33
+    private static let persistedSamplesPerHour = 360
34 34
     private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
35 35
 
36 36
     private let context: NSManagedObjectContext
@@ -741,6 +741,104 @@ final class ChargeInsightsStore {
741 741
         return didSave
742 742
     }
743 743
 
744
+    @discardableResult
745
+    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
746
+        var didSave = false
747
+        context.performAndWait {
748
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
749
+                return
750
+            }
751
+
752
+            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
753
+            let sessionEnd   = dateValue(session, key: "endedAt")
754
+                ?? dateValue(session, key: "lastObservedAt")
755
+                ?? Date.distantFuture
756
+
757
+            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
758
+            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
759
+            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
760
+            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
761
+
762
+            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
763
+                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
764
+                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
765
+                    return (
766
+                        timestamp: ts,
767
+                        energy: doubleValue(obj, key: "measuredEnergyWh"),
768
+                        charge: doubleValue(obj, key: "measuredChargeAh")
769
+                    )
770
+                }
771
+                .sorted { $0.timestamp < $1.timestamp }
772
+
773
+            // Each sample stores cumulative energy since session start.
774
+            // Trimmed energy = value at trimEnd  -  value just before trimStart.
775
+            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
776
+            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
777
+            let baselineEnergy = baselineSample?.energy ?? 0
778
+            let baselineCharge = baselineSample?.charge ?? 0
779
+
780
+            if let endSample {
781
+                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
782
+                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
783
+                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
784
+                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
785
+            } else {
786
+                session.setValue(0, forKey: "measuredEnergyWh")
787
+                session.setValue(0, forKey: "measuredChargeAh")
788
+            }
789
+
790
+            session.setValue(persistedStart, forKey: "trimStart")
791
+            session.setValue(persistedEnd,   forKey: "trimEnd")
792
+            session.setValue(Date(), forKey: "updatedAt")
793
+
794
+            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
795
+            for checkpoint in checkpoints {
796
+                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
797
+
798
+                if timestamp < effectiveStart || timestamp > effectiveEnd {
799
+                    context.delete(checkpoint)
800
+                    continue
801
+                }
802
+
803
+                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
804
+                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
805
+                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
806
+                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
807
+                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
808
+            }
809
+
810
+            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
811
+                .sorted {
812
+                    (dateValue($0, key: "timestamp") ?? .distantPast)
813
+                        < (dateValue($1, key: "timestamp") ?? .distantPast)
814
+                }
815
+            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
816
+                let label = stringValue(checkpoint, key: "label")
817
+                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
818
+                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
819
+            }
820
+
821
+            if persistedStart == nil {
822
+                if let restoredInitialCheckpoint,
823
+                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
824
+                   percent >= 0 {
825
+                    session.setValue(percent, forKey: "startBatteryPercent")
826
+                }
827
+            } else {
828
+                session.setValue(nil, forKey: "startBatteryPercent")
829
+            }
830
+
831
+            refreshCheckpointDerivedValues(for: session)
832
+
833
+            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
834
+                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
835
+            }
836
+
837
+            didSave = saveContext()
838
+        }
839
+        return didSave
840
+    }
841
+
744 842
     @discardableResult
745 843
     func deleteChargeSession(id sessionID: UUID) -> Bool {
746 844
         var didSave = false
@@ -1566,7 +1664,9 @@ final class ChargeInsightsStore {
1566 1664
                 Anchor(
1567 1665
                     percent: startBatteryPercent,
1568 1666
                     energyWh: 0,
1569
-                    timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast,
1667
+                    timestamp: dateValue(session, key: "trimStart")
1668
+                        ?? dateValue(session, key: "startedAt")
1669
+                        ?? Date.distantPast,
1570 1670
                     isCheckpoint: false
1571 1671
                 )
1572 1672
             )
@@ -1650,33 +1750,94 @@ final class ChargeInsightsStore {
1650 1750
         session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1651 1751
         session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1652 1752
 
1653
-        guard
1654
-            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1655
-            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1656
-        else {
1753
+        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1754
+
1755
+        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1657 1756
             session.setValue(nil, forKey: "capacityEstimateWh")
1658 1757
             return
1659 1758
         }
1660 1759
 
1661
-        guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
1760
+        struct CapacityAnchor {
1761
+            let percent: Double
1762
+            let energyWh: Double
1763
+            let timestamp: Date
1764
+        }
1765
+
1766
+        var anchors: [CapacityAnchor] = []
1767
+
1768
+        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1769
+           startBatteryPercent >= 0 {
1770
+            anchors.append(
1771
+                CapacityAnchor(
1772
+                    percent: startBatteryPercent,
1773
+                    energyWh: 0,
1774
+                    timestamp: dateValue(session, key: "trimStart")
1775
+                        ?? dateValue(session, key: "startedAt")
1776
+                        ?? Date.distantPast
1777
+                )
1778
+            )
1779
+        }
1780
+
1781
+        if let sessionID = stringValue(session, key: "id") {
1782
+            anchors.append(
1783
+                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
1784
+                    guard
1785
+                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
1786
+                        percent >= 0,
1787
+                        let timestamp = dateValue(checkpoint, key: "timestamp")
1788
+                    else {
1789
+                        return nil
1790
+                    }
1791
+
1792
+                    return CapacityAnchor(
1793
+                        percent: percent,
1794
+                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
1795
+                        timestamp: timestamp
1796
+                    )
1797
+                }
1798
+            )
1799
+        }
1800
+
1801
+        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
1802
+           endBatteryPercent >= 0 {
1803
+            anchors.append(
1804
+                CapacityAnchor(
1805
+                    percent: endBatteryPercent,
1806
+                    energyWh: effectiveBatteryEnergyWh,
1807
+                    timestamp: dateValue(session, key: "endedAt")
1808
+                        ?? dateValue(session, key: "lastObservedAt")
1809
+                        ?? Date.distantPast
1810
+                )
1811
+            )
1812
+        }
1813
+
1814
+        let sortedAnchors = anchors.sorted { lhs, rhs in
1815
+            if lhs.energyWh != rhs.energyWh {
1816
+                return lhs.energyWh < rhs.energyWh
1817
+            }
1818
+            return lhs.timestamp < rhs.timestamp
1819
+        }
1820
+
1821
+        guard let firstAnchor = sortedAnchors.first,
1822
+              let lastAnchor = sortedAnchors.last else {
1662 1823
             session.setValue(nil, forKey: "capacityEstimateWh")
1663 1824
             return
1664 1825
         }
1665 1826
 
1666
-        let percentDelta = endBatteryPercent - startBatteryPercent
1667
-        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1827
+        let percentDelta = lastAnchor.percent - firstAnchor.percent
1828
+        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
1668 1829
 
1669
-        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1830
+        guard percentDelta >= 20, energyDelta > 0 else {
1670 1831
             session.setValue(nil, forKey: "capacityEstimateWh")
1671 1832
             return
1672 1833
         }
1673 1834
 
1674
-        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1835
+        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
1675 1836
             session.setValue(nil, forKey: "capacityEstimateWh")
1676 1837
             return
1677 1838
         }
1678 1839
 
1679
-        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1840
+        let capacityEstimateWh = energyDelta / (percentDelta / 100)
1680 1841
         session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1681 1842
     }
1682 1843
 
@@ -2205,6 +2366,8 @@ final class ChargeInsightsStore {
2205 2366
             completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2206 2367
             completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2207 2368
             selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
2369
+            trimStart: dateValue(object, key: "trimStart"),
2370
+            trimEnd: dateValue(object, key: "trimEnd"),
2208 2371
             checkpoints: checkpointSummaries,
2209 2372
             aggregatedSamples: sampleSummaries
2210 2373
         )
+117 -40
USB Meter/Model/Meter.swift
@@ -71,6 +71,12 @@ enum ChargeRecordState {
71 71
 }
72 72
 
73 73
 class Meter : NSObject, ObservableObject, Identifiable {
74
+    private struct ChargeRecordRestoreSignature: Equatable {
75
+        let sessionID: UUID
76
+        let sampleCount: Int
77
+        let lastSampleTimestamp: Date?
78
+    }
79
+
74 80
 
75 81
     private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool {
76 82
         switch (oldValue, newValue) {
@@ -428,7 +434,8 @@ class Meter : NSObject, ObservableObject, Identifiable {
428 434
 
429 435
     var btSerial: BluetoothSerial
430 436
     
431
-    var measurements = Measurements()
437
+    let measurements = Measurements()
438
+    let chargeRecordMeasurements = Measurements()
432 439
 
433 440
     private let minimumLivePollingInterval: TimeInterval = 0.4
434 441
     private var commandQueue: [Data] = []
@@ -530,6 +537,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
530 537
     private var pendingVolatileMemoryResetDeadline: Date?
531 538
     private var liveDataChanged = false
532 539
     private var restoredChargeSessionID: UUID?
540
+    private var restoredChargeRecordSignature: ChargeRecordRestoreSignature?
533 541
     private var lastRecorderObservationAt: Date?
534 542
         
535 543
     @discardableResult
@@ -590,6 +598,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
590 598
 
591 599
     private func handleMeasurementDiscontinuity(at timestamp: Date) {
592 600
         measurements.markDiscontinuity(at: timestamp)
601
+        chargeRecordMeasurements.markDiscontinuity(at: timestamp)
593 602
 
594 603
         guard chargeRecordState == .active else { return }
595 604
         chargeRecordLastTimestamp = nil
@@ -807,27 +816,10 @@ class Meter : NSObject, ObservableObject, Identifiable {
807 816
             }
808 817
         }
809 818
         updateChargeRecord(at: dataDumpRequestTimestamp)
810
-        if supportsRecordingView {
811
-            measurements.captureEnergyValue(
812
-                timestamp: dataDumpRequestTimestamp,
813
-                value: recordedWH,
814
-                groupID: .max
815
-            )
816
-        } else if let energySample = currentEnergySample() {
817
-            measurements.captureEnergyValue(
818
-                timestamp: dataDumpRequestTimestamp,
819
-                value: energySample.value,
820
-                groupID: energySample.groupID
821
-            )
819
+        captureLiveMeasurements(at: dataDumpRequestTimestamp, in: measurements)
820
+        if chargeRecordState != .waitingForStart {
821
+            captureLiveMeasurements(at: dataDumpRequestTimestamp, in: chargeRecordMeasurements)
822 822
         }
823
-        measurements.addValues(
824
-            timestamp: dataDumpRequestTimestamp,
825
-            power: power,
826
-            voltage: voltage,
827
-            current: current,
828
-            temperature: displayedTemperatureValue,
829
-            rssi: Double(btSerial.averageRSSI)
830
-        )
831 823
         appData.observeChargeSnapshot(from: self, observedAt: dataDumpRequestTimestamp)
832 824
 //        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
833 825
 //            //track("\(name) - Scheduled new request.")
@@ -974,35 +966,120 @@ class Meter : NSObject, ObservableObject, Identifiable {
974 966
         chargeRecordLastTimestamp = nil
975 967
         chargeRecordLastCurrent = 0
976 968
         chargeRecordLastPower = 0
969
+        restoredChargeSessionID = nil
970
+        restoredChargeRecordSignature = nil
971
+        chargeRecordMeasurements.resetSeries()
977 972
         objectWillChange.send()
978 973
     }
979 974
 
980 975
     func resetChargeRecordGraph() {
981
-        let cutoff = Date()
982 976
         resetChargeRecord()
983
-        measurements.trim(before: cutoff)
984 977
     }
985 978
 
986 979
     func restoreChargeRecordIfNeeded(from activeSession: ChargeSessionSummary) {
987
-        guard chargeRecordState == .waitingForStart else { return }
988
-        guard chargeRecordStartTimestamp == nil else { return }
989
-        guard chargeRecordAH == 0, chargeRecordWH == 0, chargeRecordDuration == 0 else { return }
990
-
991
-        measurements.restorePersistedChargeSessionSamplesIfNeeded(from: activeSession)
992
-        chargeRecordState = .active
993
-        chargeRecordAH = activeSession.measuredChargeAh
994
-        chargeRecordWH = activeSession.measuredEnergyWh
995
-        chargeRecordDuration = max(activeSession.effectiveDuration, 0)
996
-        chargeRecordStopThreshold = activeSession.stopThresholdAmps
997
-        chargeRecordStartTimestamp = activeSession.startedAt
998
-        chargeRecordEndTimestamp = activeSession.lastObservedAt
999
-        chargeRecordLastTimestamp = nil
1000
-        chargeRecordLastCurrent = 0
1001
-        chargeRecordLastPower = 0
980
+        var didChange = false
981
+        let restoreSignature = ChargeRecordRestoreSignature(
982
+            sessionID: activeSession.id,
983
+            sampleCount: activeSession.aggregatedSamples.count,
984
+            lastSampleTimestamp: activeSession.aggregatedSamples.last?.timestamp
985
+        )
986
+
987
+        if restoreSignature != restoredChargeRecordSignature {
988
+            chargeRecordMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
989
+                from: activeSession,
990
+                replacingLiveBufferIfNeeded: activeSession.aggregatedSamples.isEmpty == false
991
+            )
992
+            if activeSession.aggregatedSamples.isEmpty == false {
993
+                restoredChargeRecordSignature = restoreSignature
994
+                didChange = true
995
+            }
996
+        }
997
+
998
+        if chargeRecordState != .active {
999
+            chargeRecordState = .active
1000
+            didChange = true
1001
+        }
1002
+
1003
+        let resolvedChargeAH = max(chargeRecordAH, activeSession.measuredChargeAh)
1004
+        if resolvedChargeAH != chargeRecordAH {
1005
+            chargeRecordAH = resolvedChargeAH
1006
+            didChange = true
1007
+        }
1008
+
1009
+        let resolvedChargeWH = max(chargeRecordWH, activeSession.measuredEnergyWh)
1010
+        if resolvedChargeWH != chargeRecordWH {
1011
+            chargeRecordWH = resolvedChargeWH
1012
+            didChange = true
1013
+        }
1014
+
1015
+        let resolvedDuration = max(chargeRecordDuration, max(activeSession.effectiveDuration, 0))
1016
+        if resolvedDuration != chargeRecordDuration {
1017
+            chargeRecordDuration = resolvedDuration
1018
+            didChange = true
1019
+        }
1020
+
1021
+        if chargeRecordStopThreshold != activeSession.stopThresholdAmps {
1022
+            chargeRecordStopThreshold = activeSession.stopThresholdAmps
1023
+            didChange = true
1024
+        }
1025
+
1026
+        if let chargeRecordStartTimestamp {
1027
+            let restoredStart = min(chargeRecordStartTimestamp, activeSession.startedAt)
1028
+            if restoredStart != chargeRecordStartTimestamp {
1029
+                self.chargeRecordStartTimestamp = restoredStart
1030
+                didChange = true
1031
+            }
1032
+        } else {
1033
+            chargeRecordStartTimestamp = activeSession.startedAt
1034
+            didChange = true
1035
+        }
1036
+
1037
+        if let chargeRecordEndTimestamp {
1038
+            let restoredEnd = max(chargeRecordEndTimestamp, activeSession.lastObservedAt)
1039
+            if restoredEnd != chargeRecordEndTimestamp {
1040
+                self.chargeRecordEndTimestamp = restoredEnd
1041
+                didChange = true
1042
+            }
1043
+        } else {
1044
+            chargeRecordEndTimestamp = activeSession.lastObservedAt
1045
+            didChange = true
1046
+        }
1047
+
1002 1048
         if let selectedDataGroup = activeSession.selectedDataGroup {
1003
-            self.selectedDataGroup = selectedDataGroup
1049
+            if self.selectedDataGroup != selectedDataGroup {
1050
+                self.selectedDataGroup = selectedDataGroup
1051
+                didChange = true
1052
+            }
1004 1053
         }
1005
-        objectWillChange.send()
1054
+
1055
+        if didChange {
1056
+            objectWillChange.send()
1057
+        }
1058
+    }
1059
+
1060
+    private func captureLiveMeasurements(at timestamp: Date, in destination: Measurements) {
1061
+        if supportsRecordingView {
1062
+            destination.captureEnergyValue(
1063
+                timestamp: timestamp,
1064
+                value: recordedWH,
1065
+                groupID: .max
1066
+            )
1067
+        } else if let energySample = currentEnergySample() {
1068
+            destination.captureEnergyValue(
1069
+                timestamp: timestamp,
1070
+                value: energySample.value,
1071
+                groupID: energySample.groupID
1072
+            )
1073
+        }
1074
+
1075
+        destination.addValues(
1076
+            timestamp: timestamp,
1077
+            power: power,
1078
+            voltage: voltage,
1079
+            current: current,
1080
+            temperature: displayedTemperatureValue,
1081
+            rssi: Double(btSerial.averageRSSI)
1082
+        )
1006 1083
     }
1007 1084
 
1008 1085
     func restoreChargeMonitoringIfNeeded(from activeSession: ChargeSessionSummary) {
+1 -43
USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -10,29 +10,10 @@ import SwiftUI
10 10
 
11 11
 struct ChargeRecordSheetView: View {
12 12
     @Binding var visibility: Bool
13
-    @EnvironmentObject private var appData: AppData
14
-
15
-    @State private var chargedDeviceLibraryVisibility = false
16
-    @State private var chargerLibraryVisibility = false
17
-    @State private var deviceLibraryMACAddress = ""
18
-    @State private var chargerLibraryMACAddress = ""
19
-    @State private var chargedDeviceLibraryTint: Color = .orange
20
-    @State private var chargerLibraryTint: Color = .pink
21 13
 
22 14
     var body: some View {
23 15
         NavigationView {
24
-            MeterChargeRecordContentView(
25
-                onSelectDevice: { mac, tint in
26
-                    deviceLibraryMACAddress = mac
27
-                    chargedDeviceLibraryTint = tint
28
-                    chargedDeviceLibraryVisibility = true
29
-                },
30
-                onSelectCharger: { mac, tint in
31
-                    chargerLibraryMACAddress = mac
32
-                    chargerLibraryTint = tint
33
-                    chargerLibraryVisibility = true
34
-                }
35
-            )
16
+            MeterChargeRecordContentView()
36 17
             .navigationTitle("Charge Record")
37 18
             .navigationBarTitleDisplayMode(.inline)
38 19
             .toolbar {
@@ -42,29 +23,6 @@ struct ChargeRecordSheetView: View {
42 23
                     }
43 24
                 }
44 25
             }
45
-            .background(
46
-                Group {
47
-                    NavigationLink(isActive: $chargedDeviceLibraryVisibility) {
48
-                        ChargedDeviceLibrarySheetView(
49
-                            meterMACAddress: deviceLibraryMACAddress,
50
-                            meterTint: chargedDeviceLibraryTint,
51
-                            mode: .device,
52
-                            standalone: false
53
-                        )
54
-                        .environmentObject(appData)
55
-                    } label: { EmptyView() }
56
-
57
-                    NavigationLink(isActive: $chargerLibraryVisibility) {
58
-                        ChargedDeviceLibrarySheetView(
59
-                            meterMACAddress: chargerLibraryMACAddress,
60
-                            meterTint: chargerLibraryTint,
61
-                            mode: .charger,
62
-                            standalone: false
63
-                        )
64
-                        .environmentObject(appData)
65
-                    } label: { EmptyView() }
66
-                }
67
-            )
68 26
         }
69 27
         .navigationViewStyle(StackNavigationViewStyle())
70 28
     }
+256 -87
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -10,44 +10,8 @@ struct MeterChargeRecordTabView: View, Equatable {
10 10
         true
11 11
     }
12 12
 
13
-    @EnvironmentObject private var appData: AppData
14
-
15
-    @State private var chargedDeviceLibraryVisibility = false
16
-    @State private var chargerLibraryVisibility = false
17
-    @State private var deviceLibraryMACAddress = ""
18
-    @State private var chargerLibraryMACAddress = ""
19
-    @State private var chargedDeviceLibraryTint: Color = .orange
20
-    @State private var chargerLibraryTint: Color = .pink
21
-
22 13
     var body: some View {
23
-        MeterChargeRecordContentView(
24
-            onSelectDevice: { mac, tint in
25
-                deviceLibraryMACAddress = mac
26
-                chargedDeviceLibraryTint = tint
27
-                chargedDeviceLibraryVisibility = true
28
-            },
29
-            onSelectCharger: { mac, tint in
30
-                chargerLibraryMACAddress = mac
31
-                chargerLibraryTint = tint
32
-                chargerLibraryVisibility = true
33
-            }
34
-        )
35
-        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
36
-            ChargedDeviceLibrarySheetView(
37
-                meterMACAddress: deviceLibraryMACAddress,
38
-                meterTint: chargedDeviceLibraryTint,
39
-                mode: .device
40
-            )
41
-            .environmentObject(appData)
42
-        }
43
-        .sheet(isPresented: $chargerLibraryVisibility) {
44
-            ChargedDeviceLibrarySheetView(
45
-                meterMACAddress: chargerLibraryMACAddress,
46
-                meterTint: chargerLibraryTint,
47
-                mode: .charger
48
-            )
49
-            .environmentObject(appData)
50
-        }
14
+        MeterChargeRecordContentView()
51 15
     }
52 16
 }
53 17
 
@@ -134,9 +98,6 @@ struct MeterChargeRecordContentView: View {
134 98
         }
135 99
     }
136 100
 
137
-    let onSelectDevice: (String, Color) -> Void
138
-    let onSelectCharger: (String, Color) -> Void
139
-
140 101
     @EnvironmentObject private var appData: AppData
141 102
     @EnvironmentObject private var usbMeter: Meter
142 103
 
@@ -152,6 +113,16 @@ struct MeterChargeRecordContentView: View {
152 113
     @State private var initialCheckpoint = ""
153 114
     @State private var showsMeterTotalsInfo = false
154 115
     @State private var activeMode: ActiveMode = .chargeSession
116
+    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
117
+    @State private var trimBannerDismissedForSessionID: UUID?
118
+
119
+    private var shouldShowTrimBanner: Bool {
120
+        guard let session = openChargeSession,
121
+              session.isTrimmed == false,
122
+              trimBannerDismissedForSessionID != session.id else { return false }
123
+        guard let window = detectedTrimWindow else { return false }
124
+        return window.trimRatio > ChargingWindowDetector.significantTrimThreshold
125
+    }
155 126
 
156 127
     var body: some View {
157 128
         ScrollView {
@@ -161,8 +132,15 @@ struct MeterChargeRecordContentView: View {
161 132
                 if let openChargeSession {
162 133
                     chargingMonitorCard(openChargeSession)
163 134
 
164
-                    if let range = sessionChartTimeRange {
165
-                        sessionChartCard(timeRange: range, session: openChargeSession)
135
+                    if shouldShowTrimBanner {
136
+                        trimDetectionBanner(for: openChargeSession)
137
+                    }
138
+
139
+                    if shouldShowSessionChart(for: openChargeSession) {
140
+                        sessionChartCard(
141
+                            timeRange: sessionChartFixedTimeRange(for: openChargeSession),
142
+                            session: openChargeSession
143
+                        )
166 144
                     }
167 145
                 } else {
168 146
                     liveMeterStripView
@@ -202,16 +180,48 @@ struct MeterChargeRecordContentView: View {
202 180
             )
203 181
         }
204 182
         .onAppear {
183
+            syncActiveSessionRestore()
205 184
             syncDraftSelections()
185
+            runTrimDetection()
206 186
         }
207 187
         .onChange(of: selectedChargedDevice?.id) { _ in
208 188
             syncDraftSelections()
209 189
         }
210 190
         .onChange(of: openChargeSession?.id) { _ in
191
+            syncActiveSessionRestore()
211 192
             syncDraftSelections()
212 193
             showingInlineTargetEditor = false
213 194
             draftTargetText = ""
195
+            detectedTrimWindow = nil
196
+            trimBannerDismissedForSessionID = nil
197
+            runTrimDetection()
198
+        }
199
+        .onChange(of: openChargeSession?.aggregatedSamples.count) { _ in
200
+            syncActiveSessionRestore()
201
+            runTrimDetection()
202
+        }
203
+    }
204
+
205
+    private func syncActiveSessionRestore() {
206
+        guard let session = openChargeSession else { return }
207
+        guard session.status == .active else { return }
208
+        guard session.meterMACAddress == meterMACAddress else { return }
209
+        usbMeter.restoreChargeRecordIfNeeded(from: session)
210
+    }
211
+
212
+    private func runTrimDetection() {
213
+        guard let session = openChargeSession,
214
+              session.isTrimmed == false,
215
+              !session.aggregatedSamples.isEmpty else {
216
+            detectedTrimWindow = nil
217
+            return
214 218
         }
219
+        let sessionEnd = session.endedAt ?? session.lastObservedAt
220
+        detectedTrimWindow = ChargingWindowDetector.detect(
221
+            samples: session.aggregatedSamples,
222
+            sessionStart: session.startedAt,
223
+            sessionEnd: sessionEnd
224
+        )
215 225
     }
216 226
 
217 227
     // MARK: - Computed Properties
@@ -224,10 +234,38 @@ struct MeterChargeRecordContentView: View {
224 234
         appData.currentChargedDeviceSummary(for: meterMACAddress)
225 235
     }
226 236
 
237
+    private var availableChargedDevices: [ChargedDeviceSummary] {
238
+        appData.deviceSummaries
239
+    }
240
+
241
+    private var selectedChargedDeviceID: Binding<UUID?> {
242
+        Binding(
243
+            get: { selectedChargedDevice?.id },
244
+            set: { newValue in
245
+                guard let newValue else { return }
246
+                _ = appData.assignChargedDevice(newValue, to: meterMACAddress)
247
+            }
248
+        )
249
+    }
250
+
227 251
     private var selectedCharger: ChargedDeviceSummary? {
228 252
         appData.currentChargerSummary(for: meterMACAddress)
229 253
     }
230 254
 
255
+    private var availableChargers: [ChargedDeviceSummary] {
256
+        appData.chargerSummaries
257
+    }
258
+
259
+    private var selectedChargerID: Binding<UUID?> {
260
+        Binding(
261
+            get: { selectedCharger?.id },
262
+            set: { newValue in
263
+                guard let newValue else { return }
264
+                _ = appData.assignCharger(newValue, to: meterMACAddress)
265
+            }
266
+        )
267
+    }
268
+
231 269
     private var openChargeSession: ChargeSessionSummary? {
232 270
         appData.activeChargeSessionSummary(for: meterMACAddress)
233 271
     }
@@ -337,10 +375,26 @@ struct MeterChargeRecordContentView: View {
337 375
         }
338 376
     }
339 377
 
340
-    private var sessionChartTimeRange: ClosedRange<Date>? {
341
-        guard let openChargeSession else { return nil }
342
-        let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
343
-        return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
378
+    private func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool {
379
+        sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session)
380
+    }
381
+
382
+    private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange<Date>? {
383
+        if usesChargeRecordBuffer(for: session) {
384
+            return nil
385
+        }
386
+        return session.effectiveTimeRange
387
+    }
388
+
389
+    private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) {
390
+        guard usesChargeRecordBuffer(for: session) else {
391
+            return (nil, nil)
392
+        }
393
+        return (session.trimStart, session.trimEnd)
394
+    }
395
+
396
+    private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool {
397
+        session.status.isOpen && session.meterMACAddress == meterMACAddress
344 398
     }
345 399
 
346 400
     private var showsWirelessChargerSection: Bool {
@@ -386,21 +440,29 @@ struct MeterChargeRecordContentView: View {
386 440
         VStack(alignment: .leading, spacing: 0) {
387 441
             // Device
388 442
             setupRow(icon: "iphone", iconColor: .blue) {
389
-                if let device = selectedChargedDevice {
390
-                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
391
-                        .font(.subheadline.weight(.semibold))
392
-                } else {
393
-                    Text("No device selected")
394
-                        .foregroundColor(.secondary)
395
-                        .font(.subheadline)
396
-                }
397
-                Spacer(minLength: 8)
398
-                Button(selectedChargedDevice == nil ? "Select" : "Change") {
399
-                    onSelectDevice(meterMACAddress, usbMeter.color)
443
+                Picker(selection: selectedChargedDeviceID) {
444
+                    Text("Choose device").tag(UUID?.none)
445
+                    ForEach(availableChargedDevices) { device in
446
+                        Text(device.name).tag(Optional(device.id))
447
+                    }
448
+                } label: {
449
+                    HStack(spacing: 8) {
450
+                        if let device = selectedChargedDevice {
451
+                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
452
+                                .font(.subheadline.weight(.semibold))
453
+                        } else {
454
+                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
455
+                                .foregroundColor(.secondary)
456
+                                .font(.subheadline)
457
+                        }
458
+                        Spacer(minLength: 8)
459
+                        Image(systemName: "chevron.up.chevron.down")
460
+                            .font(.caption.weight(.semibold))
461
+                            .foregroundColor(.secondary)
462
+                    }
400 463
                 }
401
-                .font(.caption.weight(.semibold))
402
-                .buttonStyle(.bordered)
403
-                .controlSize(.small)
464
+                .pickerStyle(.menu)
465
+                .disabled(availableChargedDevices.isEmpty)
404 466
             }
405 467
 
406 468
             // Charging type — only when device supports multiple
@@ -449,26 +511,34 @@ struct MeterChargeRecordContentView: View {
449 511
             if showsWirelessChargerSection {
450 512
                 Divider().padding(.leading, 46)
451 513
                 setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
452
-                    if let charger = selectedCharger {
453
-                        ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
454
-                            .font(.subheadline.weight(.semibold))
455
-                        if charger.chargerIdleCurrentAmps == nil {
456
-                            Image(systemName: "exclamationmark.triangle.fill")
457
-                                .foregroundColor(.orange)
458
-                                .font(.caption)
514
+                    Picker(selection: selectedChargerID) {
515
+                        Text("Choose charger").tag(UUID?.none)
516
+                        ForEach(availableChargers) { charger in
517
+                            Text(charger.name).tag(Optional(charger.id))
518
+                        }
519
+                    } label: {
520
+                        HStack(spacing: 8) {
521
+                            if let charger = selectedCharger {
522
+                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
523
+                                    .font(.subheadline.weight(.semibold))
524
+                                if charger.chargerIdleCurrentAmps == nil {
525
+                                    Image(systemName: "exclamationmark.triangle.fill")
526
+                                        .foregroundColor(.orange)
527
+                                        .font(.caption)
528
+                                }
529
+                            } else {
530
+                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
531
+                                    .foregroundColor(.secondary)
532
+                                    .font(.subheadline)
533
+                            }
534
+                            Spacer(minLength: 8)
535
+                            Image(systemName: "chevron.up.chevron.down")
536
+                                .font(.caption.weight(.semibold))
537
+                                .foregroundColor(.secondary)
459 538
                         }
460
-                    } else {
461
-                        Text("No charger selected")
462
-                            .foregroundColor(.secondary)
463
-                            .font(.subheadline)
464
-                    }
465
-                    Spacer(minLength: 8)
466
-                    Button(selectedCharger == nil ? "Select" : "Change") {
467
-                        onSelectCharger(meterMACAddress, usbMeter.color)
468 539
                     }
469
-                    .font(.caption.weight(.semibold))
470
-                    .buttonStyle(.bordered)
471
-                    .controlSize(.small)
540
+                    .pickerStyle(.menu)
541
+                    .disabled(availableChargers.isEmpty)
472 542
                 }
473 543
             }
474 544
 
@@ -758,7 +828,7 @@ struct MeterChargeRecordContentView: View {
758 828
     ) -> some View {
759 829
         let percent = prediction.predictedPercent
760 830
         let color = batteryColor(for: percent)
761
-        let duration = max(session.effectiveDuration, 0)
831
+        let duration = displayedSessionDuration(for: session)
762 832
         let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
763 833
             ? displayedEnergyWh / duration
764 834
             : nil
@@ -908,6 +978,7 @@ struct MeterChargeRecordContentView: View {
908 978
         displayedEnergyWh: Double,
909 979
         hasPrediction: Bool
910 980
     ) -> some View {
981
+        let displayedDuration = displayedSessionDuration(for: session)
911 982
         let capacityFallback: Double? = hasPrediction ? nil : (
912 983
             session.capacityEstimateWh
913 984
                 ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
@@ -917,7 +988,7 @@ struct MeterChargeRecordContentView: View {
917 988
 
918 989
         return LazyVGrid(columns: columns, spacing: 8) {
919 990
             metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
920
-            metricCell(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0)), tint: .teal)
991
+            metricCell(label: "Duration", value: formatDuration(displayedDuration), tint: .teal)
921 992
 
922 993
             if shouldShowChargingTransport(for: session) {
923 994
                 metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
@@ -1253,7 +1324,62 @@ struct MeterChargeRecordContentView: View {
1253 1324
         finalCheckpointText = next.format(decimalDigits: 0)
1254 1325
     }
1255 1326
 
1256
-    private func sessionChartCard(timeRange: ClosedRange<Date>, session: ChargeSessionSummary) -> some View {
1327
+    // MARK: - Trim Detection Banner
1328
+
1329
+    @ViewBuilder
1330
+    private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View {
1331
+        if let window = detectedTrimWindow {
1332
+            HStack(spacing: 12) {
1333
+                Image(systemName: "scissors.circle.fill")
1334
+                    .font(.title3)
1335
+                    .foregroundColor(.blue)
1336
+
1337
+                VStack(alignment: .leading, spacing: 2) {
1338
+                    Text("Charging ended early")
1339
+                        .font(.subheadline.weight(.semibold))
1340
+                    Text("Active charging detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")). The rest may be standby or another device.")
1341
+                        .font(.caption)
1342
+                        .foregroundColor(.secondary)
1343
+                        .fixedSize(horizontal: false, vertical: true)
1344
+                }
1345
+
1346
+                Spacer(minLength: 0)
1347
+
1348
+                VStack(spacing: 6) {
1349
+                    Button("Apply") {
1350
+                        _ = appData.setSessionTrim(
1351
+                            sessionID: session.id,
1352
+                            start: window.start,
1353
+                            end: window.end
1354
+                        )
1355
+                        trimBannerDismissedForSessionID = session.id
1356
+                    }
1357
+                    .font(.caption.weight(.semibold))
1358
+                    .buttonStyle(.borderedProminent)
1359
+                    .controlSize(.small)
1360
+                    .tint(.blue)
1361
+
1362
+                    Button {
1363
+                        trimBannerDismissedForSessionID = session.id
1364
+                    } label: {
1365
+                        Image(systemName: "xmark")
1366
+                            .font(.caption2.weight(.semibold))
1367
+                            .foregroundColor(.secondary)
1368
+                    }
1369
+                    .buttonStyle(.plain)
1370
+                }
1371
+            }
1372
+            .padding(14)
1373
+            .background(
1374
+                RoundedRectangle(cornerRadius: 14)
1375
+                    .fill(Color.blue.opacity(0.10))
1376
+                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1377
+            )
1378
+            .transition(.opacity.combined(with: .move(edge: .top)))
1379
+        }
1380
+    }
1381
+
1382
+    private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
1257 1383
         VStack(alignment: .leading, spacing: 12) {
1258 1384
             HStack(spacing: 8) {
1259 1385
                 Image(systemName: "chart.xyaxis.line")
@@ -1262,8 +1388,11 @@ struct MeterChargeRecordContentView: View {
1262 1388
                     .font(.headline)
1263 1389
                 ContextInfoButton(
1264 1390
                     title: "Session Chart",
1265
-                    message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1391
+                    message: usesChargeRecordBuffer(for: session)
1392
+                        ? "This chart combines the persisted session curve with current live data from this meter."
1393
+                        : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1266 1394
                 )
1395
+                Spacer(minLength: 0)
1267 1396
             }
1268 1397
 
1269 1398
             GeometryReader { geometry in
@@ -1275,10 +1404,41 @@ struct MeterChargeRecordContentView: View {
1275 1404
                     compactLayout: compactChartLayout,
1276 1405
                     availableSize: CGSize(width: chartWidth, height: chartHeight),
1277 1406
                     timeRange: timeRange,
1278
-                    showsRangeSelector: false,
1279
-                    rebasesEnergyToVisibleRangeStart: true
1407
+                    timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1408
+                    timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
1409
+                    showsRangeSelector: session.aggregatedSamples.isEmpty == false,
1410
+                    rebasesEnergyToVisibleRangeStart: true,
1411
+                    extendsTimelineToPresent: false,
1412
+                    rangeSelectorConfiguration: session.aggregatedSamples.isEmpty
1413
+                        ? nil
1414
+                        : MeasurementChartRangeSelectorConfiguration(
1415
+                            keepAction: MeasurementChartSelectionAction(
1416
+                                title: compactChartLayout ? "Keep" : "Keep Selection",
1417
+                                systemName: "scissors",
1418
+                                tone: .destructive,
1419
+                                handler: { range in
1420
+                                    _ = appData.setSessionTrim(
1421
+                                        sessionID: session.id,
1422
+                                        start: range.lowerBound,
1423
+                                        end: range.upperBound
1424
+                                    )
1425
+                                    trimBannerDismissedForSessionID = session.id
1426
+                                }
1427
+                            ),
1428
+                            removeAction: nil,
1429
+                            resetAction: MeasurementChartResetAction(
1430
+                                title: compactChartLayout ? "Reset" : "Reset Trim",
1431
+                                systemName: "arrow.counterclockwise",
1432
+                                tone: .reversible,
1433
+                                confirmationTitle: "Reset session trim?",
1434
+                                confirmationButtonTitle: "Reset trim",
1435
+                                handler: {
1436
+                                    _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
1437
+                                }
1438
+                            )
1439
+                        )
1280 1440
                 )
1281
-                .environmentObject(usbMeter.measurements)
1441
+                .environmentObject(usbMeter.chargeRecordMeasurements)
1282 1442
                 .frame(maxWidth: .infinity, alignment: .topLeading)
1283 1443
             }
1284 1444
             .frame(height: 350)
@@ -1382,7 +1542,7 @@ struct MeterChargeRecordContentView: View {
1382 1542
         }
1383 1543
 
1384 1544
         rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
1385
-        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0))))
1545
+        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session))))
1386 1546
         rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1387 1547
         return rows
1388 1548
     }
@@ -1401,6 +1561,7 @@ struct MeterChargeRecordContentView: View {
1401 1561
 
1402 1562
     private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1403 1563
         let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1564
+        guard session.isTrimmed == false else { return storedEnergyWh }
1404 1565
         guard session.status.isOpen else { return storedEnergyWh }
1405 1566
         guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
1406 1567
         if let baselineEnergyWh = session.meterEnergyBaselineWh {
@@ -1411,6 +1572,7 @@ struct MeterChargeRecordContentView: View {
1411 1572
 
1412 1573
     private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1413 1574
         let storedChargeAh = session.measuredChargeAh
1575
+        guard session.isTrimmed == false else { return storedChargeAh }
1414 1576
         guard session.status.isOpen else { return storedChargeAh }
1415 1577
         guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
1416 1578
         if let baselineChargeAh = session.meterChargeBaselineAh {
@@ -1419,6 +1581,14 @@ struct MeterChargeRecordContentView: View {
1419 1581
         return storedChargeAh
1420 1582
     }
1421 1583
 
1584
+    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1585
+        let storedDuration = max(session.effectiveDuration, 0)
1586
+        guard session.isTrimmed == false else { return storedDuration }
1587
+        guard session.status.isOpen else { return storedDuration }
1588
+        guard session.meterMACAddress == meterMACAddress else { return storedDuration }
1589
+        return max(storedDuration, max(usbMeter.chargeRecordDuration, 0))
1590
+    }
1591
+
1422 1592
     private func formatDuration(_ duration: TimeInterval) -> String {
1423 1593
         let totalSeconds = Int(duration.rounded(.down))
1424 1594
         let hours = totalSeconds / 3600
@@ -1547,4 +1717,3 @@ struct MeterChargeRecordContentView: View {
1547 1717
         .buttonStyle(.plain)
1548 1718
     }
1549 1719
 }
1550
-