Showing 13 changed files with 1088 additions and 75 deletions
+199 -0
Documentation/External Contributions.md
@@ -0,0 +1,199 @@
1
+# External Contributions
2
+
3
+This document tracks contributions made by collaborators outside the core project.
4
+Its purpose is to support ongoing evaluation of each contributor's work quality,
5
+patterns, and reliability over time.
6
+
7
+---
8
+
9
+## Contributor A — anonim, prieten al autorului
10
+
11
+Toate intervențiile cunoscute sunt în `AppData.swift`, `ChargeInsightsStore.swift`, și `Meter.swift`.
12
+
13
+---
14
+
15
+### Intervenție 1 — Fix lag UI la formulare (aprilie 2026)
16
+
17
+**Context:** La conectarea unui contor live, formularele din UI (typing, picker)
18
+deveneau iresponsive. `reloadChargedDevices()` rula sincron pe main thread la fiecare
19
+snapshot BLE (~1/s), blocând UIKit.
20
+
21
+**Commit:** `4f79218` *(înglobat cu Intervenția 2)*
22
+
23
+**Modificări introduse:**
24
+
25
+- `chargeInsightsReadStore` — context separat de citire (`privateQueueConcurrencyType`)
26
+- `chargedDevicesReloadQueue` — fetch mutat pe background queue
27
+- `chargedDevicesReloadGeneration` — counter de invalidare pentru fetch-uri async depășite
28
+
29
+**Bug introdus (descoperit ulterior):**
30
+
31
+Pattern-ul generation+invalidare era prea agresiv. Contorul era incrementat la fiecare
32
+apel `reloadChargedDevices()`, inclusiv cele declanșate în burst de
33
+`NSManagedObjectContextObjectsDidChange`. Orice fetch async dura >0ms → generația era
34
+deja depășită la finalizare → rezultatul era aruncat. Lista rămânea goală permanent
35
+atât timp cât un contor live era conectat, indiferent că SQLite conținea date reale.
36
+
37
+**Evaluare:** Problema identificată era reală. Soluția era funcțională în absența unui
38
+contor conectat (test static), dar s-a rupt sub load real. Eroare clasică de
39
+async invalidation, probabil netestat cu dispozitiv real.
40
+
41
+---
42
+
43
+### Intervenție 2 — Fix starvation reload (după aprilie 2026)
44
+
45
+**Context:** Lista de charged devices rămânea goală permanent când un contor era activ.
46
+Cauzat de bug-ul din Intervenția 1. Autorul a identificat și corectat singur problema.
47
+
48
+**Commit:** `4f79218`
49
+
50
+**Modificări introduse:**
51
+
52
+- Înlocuire generation counter cu pattern `inFlight`/`pending`
53
+  - `chargedDevicesReloadInFlight: Bool` — un singur fetch în zbor la un moment dat
54
+  - `chargedDevicesReloadPending: Bool` — cerere nouă marcată pentru după finalizare
55
+- `scheduleChargedDevicesReload(delay: 0.15)` — debounce pentru burst-uri de notificări
56
+
57
+**Evaluare:** Corect și complet. Soluția standard pentru problema dată. Nu introduce
58
+regresii. Diagnosticul era precis (cita dimensiunile exacte din SQLite: 10 devices,
59
+15 sesiuni, 49 checkpoints, 24.279 samples).
60
+
61
+---
62
+
63
+### Intervenție 3 — Optimizare CPU pe Catalyst (aprilie 2026)
64
+
65
+**Context:** Procesul Catalyst consuma CPU ridicat și constant. Identificat prin profiling.
66
+
67
+**Commits:** `406be0e` (împreună cu ajustări ulterioare ale intervalelor)
68
+
69
+**Cauze identificate de contributor:**
70
+
71
+1. BLE polling continuu — `dataDumpRequest()` recursiv imediat după fiecare răspuns
72
+2. Fiecare pachet BLE atingea CoreData/iCloud direct, cu reload UI frecvent
73
+3. `noteMeterSeen` scria în `MeterNameStore` la fiecare observație
74
+
75
+**Modificări introduse:**
76
+
77
+- `Meter.swift` — `minimumLivePollingInterval = 0.4s`; înlocuit recursie imediată cu
78
+  `scheduleNextLiveDataDumpRequest()` care respectă intervalul minim
79
+- `AppData.swift` — coalescere snapshot-uri BLE în memorie (`pendingChargeObservationSnapshots`);
80
+  flush periodic la 30s cu flush explicit la pause/stop/checkpoint/terminate;
81
+  throttle `noteMeterSeen` la 15s; observer schimbat din `ObjectsDidChange` → `DidSave`
82
+- `AppData.swift` — `writeContext` privat pentru `ChargeInsightsStore`
83
+  (anterior store-ul scria direct pe `viewContext`, i.e. main thread)
84
+- `ChargeInsightsStore.swift` — `maximumLiveIntegrationGap` 20s → 90s
85
+  (aliniat cu fereastra de coalescere de 30s)
86
+
87
+**Probleme rămase după livrare (rezolvate în același commit):**
88
+
89
+- Work item-ul de flush era dispatchat pe `DispatchQueue.main`; apela
90
+  `context.performAndWait` → bloca main thread la fiecare 30s (spike vizibil în profiler)
91
+- `flushPendingChargeObservation` chema explicit `reloadChargedDevices()`, iar observer-ul
92
+  DidSave chema și el `scheduleChargedDevicesReload()` — double reload per flush
93
+
94
+Aceste două probleme au fost remediate separat (fix în `406be0e`):
95
+flush-ul periodic mută scrierea CoreData pe `DispatchQueue.global(qos: .utility)`;
96
+reload-ul explicit eliminat din flush, lăsat exclusiv pe seama observer-ului DidSave.
97
+
98
+**Note privind originea problemelor rezolvate:**
99
+
100
+`NSManagedObjectContextObjectsDidChange` ca observer și `viewContext` pentru scrieri
101
+existau în codul proiectului **înainte** de orice intervenție a acestui contributor.
102
+Intervenția 3 rezolvă probleme de design preexistente, nu regresii proprii.
103
+Singura auto-corecție este `maximumLiveIntegrationGap`, ajustat pentru coalescing-ul
104
+introdus tot în această intervenție.
105
+
106
+**Evaluare:** Diagnostic corect pentru toate cele trei cauze. Modificările sunt
107
+coerente între ele. Livrarea conținea două bug-uri minore (main thread + double reload)
108
+care au necesitat remediere imediată, detectabile printr-un review atent al codului
109
+fără a fi nevoie de runtime. Efectul net: CPU redus de la consum constant la 0–4%
110
+cu spike-uri reziduale la ~30s (CloudKit sync inerent).
111
+
112
+---
113
+
114
+## Session Trim Feature — Buffer Restore Bug (pentru externalizare)
115
+
116
+### Contextul feature-ului
117
+
118
+S-a implementat un feature de "Session Trim": detectare automată a ferestrei reale de încărcare (ex: Apple Watch se încarcă 3h dar sesiunea rulează 16h overnight) + editor manual pentru trim. Feature-ul este complet implementat și compilează fără erori.
119
+
120
+**Există un bug pre-existent** care blochează funcționarea feature-ului și trebuie rezolvat separat.
121
+
122
+---
123
+
124
+### Bug: istoricul sesiunii se pierde la fiecare restart de aplicație
125
+
126
+**Simptom:** La restart cu sesiune activă, graficul afișează intervalul complet al sesiunii (ex: 16h) dar cu date doar din momentul reconectării BLE. Istoricul din Core Data nu este restaurat.
127
+
128
+**Consecință pentru trim:** Banner-ul de trim nu apare (se bazează pe `aggregatedSamples` gol), editorul de trim are graficul gol.
129
+
130
+---
131
+
132
+### Unde este problema
133
+
134
+**`Meter.restoreChargeRecordIfNeeded(from:)`** — `USB Meter/Model/Meter.swift`
135
+
136
+```swift
137
+func restoreChargeRecordIfNeeded(from activeSession: ChargeSessionSummary) {
138
+    guard chargeRecordState == .waitingForStart else { return }  // ← problema
139
+    guard chargeRecordStartTimestamp == nil else { return }
140
+    guard chargeRecordAH == 0, chargeRecordWH == 0, chargeRecordDuration == 0 else { return }
141
+
142
+    measurements.restorePersistedChargeSessionSamplesIfNeeded(from: activeSession)
143
+    ...
144
+}
145
+```
146
+
147
+**Race condition:** Datele BLE ajung înaintea completării `reloadChargedDevices` (async). La primul pachet BLE, `chargeRecordState` trece `waitingForStart → active`. Când `reloadChargedDevices` finalizează și încearcă restore, primul guard blochează — permanent pentru sesiunea curentă.
148
+
149
+**`Measurements.restorePersistedChargeSessionSamplesIfNeeded`** are propriul guard: returnează dacă vreun series este non-gol. Dacă se rezolvă problema de mai sus și datele BLE au umplut deja buffer-ul, acesta trebuie resetat înainte de restore — dar **numai dacă `aggregatedSamples` nu este gol**, altfel graficul rămâne gol definitiv.
150
+
151
+---
152
+
153
+### Lanțul de apel la pornire
154
+
155
+```text
156
+SceneDelegate.activateCloudDeviceSync
157
+  → AppData.activateChargeInsights        // setup writeContext + readContext
158
+  → AppData.scheduleChargedDevicesReload  // async, ~100–500ms
159
+
160
+[BLE se conectează, date ajung — pe main queue]
161
+  → Meter.processData → Meter.updateChargeRecord
162
+    → chargeRecordState = .active  // ← guard-ul din restore va bloca
163
+
164
+[reloadChargedDevices completează]
165
+  → AppData.restoreChargeMonitoringStateIfNeeded(for: meter)
166
+    → Meter.restoreChargeRecordIfNeeded(from: session)  // ← guard blochează
167
+```
168
+
169
+---
170
+
171
+### Fișiere modificate de feature (necommitted, diff disponibil)
172
+
173
+| Fișier | Modificare |
174
+| ------ | ---------- |
175
+| `USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 15.xcdatamodel/` | Nou — adaugă `trimStart`/`trimEnd` opționale pe `ChargeSession` |
176
+| `USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion` | → v15 |
177
+| `USB Meter/Model/ChargingWindowDetector.swift` | Nou — detectează fereastra activă de încărcare |
178
+| `USB Meter/Views/Meter/Tabs/ChargeRecord/SessionTrimEditorView.swift` | Nou — editor trim cu drag handles |
179
+| `USB Meter/Model/ChargeInsightsModel.swift` | `trimStart`, `trimEnd`, `isTrimmed` pe `ChargeSessionSummary` |
180
+| `USB Meter/Model/ChargeInsightsStore.swift` | `setSessionTrim(sessionID:start:end:)` — recalculează energie, șterge checkpoints |
181
+| `USB Meter/Model/AppData.swift` | Wrapper `setSessionTrim` |
182
+| `USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift` | Banner detecție + sheet editor |
183
+| `USB Meter.xcodeproj/project.pbxproj` | Referințe fișiere noi |
184
+
185
+---
186
+
187
+## Tipare observate
188
+
189
+| | Interv. 1 | Interv. 2 | Interv. 3 |
190
+|---|---|---|---|
191
+| Diagnostic corect | Da | Da | Da |
192
+| Funcționează fără load real | Da | Da | Da |
193
+| Funcționează sub load real | Nu | Da | Parțial |
194
+| Auto-corecție necesară | — | Da (proprie) | Parțial (externă) |
195
+| Regresii introduse | Da (starvation) | Nu | Nu |
196
+
197
+**Observație recurentă:** Contribuțiile funcționează corect în condiții statice dar
198
+au tendința să rateze edge case-uri sub load real (contor live conectat). Testele
199
+înainte de livrare par să nu includă scenariul cu dispozitiv activ.
+2 -0
Documentation/README.md
@@ -14,6 +14,8 @@ It is intended to keep the repository root focused on the app itself while prese
14 14
   Definition + measurement implications for capacity estimation.
15 15
 - `Project Structure and Naming.md`
16 16
   Naming and file-organization rules for views, features, components, and subviews.
17
+- `External Contributions.md`
18
+  Log of contributions from external collaborators, with technical evaluation per intervention.
17 19
 - `Research Resources/`
18 20
   External source material plus the notes derived from it.
19 21
 
+11 -1
USB Meter.xcodeproj/project.pbxproj
@@ -56,6 +56,8 @@
56 56
 		B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */; };
57 57
 		C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */; };
58 58
 		C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */; };
59
+		F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */; };
60
+		F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */; };
59 61
 		C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */; };
60 62
 		C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */; };
61 63
 		C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */; };
@@ -141,6 +143,7 @@
141 143
 		C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ChargedDeviceTemplates.json; sourceTree = "<group>"; };
142 144
 		C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 12.xcdatamodel"; sourceTree = "<group>"; };
143 145
 		E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
146
+		F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
144 147
 		4383B467240F845500DAAEBF /* MacAdress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacAdress.swift; sourceTree = "<group>"; };
145 148
 		4383B469240FE4A600DAAEBF /* MeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterView.swift; sourceTree = "<group>"; };
146 149
 		438695882463F062008855A9 /* Measurements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurements.swift; sourceTree = "<group>"; };
@@ -173,6 +176,8 @@
173 176
 		B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerWizardView.swift; sourceTree = "<group>"; };
174 177
 		C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsModel.swift; sourceTree = "<group>"; };
175 178
 		C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsStore.swift; sourceTree = "<group>"; };
179
+		F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
180
+		F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTrimEditorView.swift; sourceTree = "<group>"; };
176 181
 		C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceQRCodeView.swift; sourceTree = "<group>"; };
177 182
 		C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorSheetView.swift; sourceTree = "<group>"; };
178 183
 		C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibrarySheetView.swift; sourceTree = "<group>"; };
@@ -431,6 +436,7 @@
431 436
 				4383B461240EB5E400DAAEBF /* AppData.swift */,
432 437
 				C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */,
433 438
 				C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */,
439
+				F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */,
434 440
 				B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */,
435 441
 				C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */,
436 442
 				7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */,
@@ -651,6 +657,7 @@
651 657
 			isa = PBXGroup;
652 658
 			children = (
653 659
 				D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */,
660
+				F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */,
654 661
 			);
655 662
 			path = ChargeRecord;
656 663
 			sourceTree = "<group>";
@@ -780,6 +787,8 @@
780 787
 				D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */,
781 788
 				C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */,
782 789
 				C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */,
790
+				F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */,
791
+				F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */,
783 792
 				C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */,
784 793
 				C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */,
785 794
 				C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */,
@@ -1078,8 +1087,9 @@
1078 1087
 				C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */,
1079 1088
 				C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */,
1080 1089
 				E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */,
1090
+				F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */,
1081 1091
 			);
1082
-			currentVersion = E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */;
1092
+			currentVersion = F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */;
1083 1093
 			path = CKModel.xcdatamodeld;
1084 1094
 			sourceTree = "<group>";
1085 1095
 			versionGroupType = wrapper.xcdatamodel;
+0 -1
USB Meter/Model/BluetoothManager.swift
@@ -80,7 +80,6 @@ class BluetoothManager : NSObject, ObservableObject {
80 80
             if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
81 81
                 meter.updateNameFromStore(syncedName)
82 82
             }
83
-            appData.restoreChargeMonitoringStateIfNeeded(for: meter)
84 83
             if peripheral.delegate == nil {
85 84
                 peripheral.delegate = meter.btSerial
86 85
             }
+1 -1
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -3,6 +3,6 @@
3 3
 <plist version="1.0">
4 4
 <dict>
5 5
 	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter 14.xcdatamodel</string>
6
+	<string>USB_Meter 15.xcdatamodel</string>
7 7
 </dict>
8 8
 </plist>
+126 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 15.xcdatamodel/contents
@@ -0,0 +1,126 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
10
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
11
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
12
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
30
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
31
+        <attribute name="notes" optional="YES" attributeType="String"/>
32
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
34
+    </entity>
35
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
36
+        <attribute name="id" optional="YES" attributeType="String"/>
37
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
38
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
39
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
40
+        <attribute name="meterName" optional="YES" attributeType="String"/>
41
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
42
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
46
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
47
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
50
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
51
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
52
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
58
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
59
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
60
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
61
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
65
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
66
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
67
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
68
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
69
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
70
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
71
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
72
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
73
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
75
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
76
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
77
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
78
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
79
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
80
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
81
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
82
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
83
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
84
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
85
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
86
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
87
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
88
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
89
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
91
+    </entity>
92
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
93
+        <attribute name="id" optional="YES" attributeType="String"/>
94
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
95
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
96
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
97
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
98
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
99
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
101
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
102
+        <attribute name="label" optional="YES" attributeType="String"/>
103
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
104
+    </entity>
105
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
106
+        <attribute name="id" optional="YES" attributeType="String"/>
107
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
108
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
109
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
110
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
111
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
112
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
113
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
114
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
115
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
116
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
117
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
118
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
119
+    </entity>
120
+    <elements>
121
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/>
122
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="883"/>
123
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
124
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
125
+    </elements>
126
+</model>
+21 -2
USB Meter/Model/ChargeInsightsModel.swift
@@ -626,9 +626,25 @@ struct ChargeSessionSummary: Identifiable, Hashable {
626 626
     let completionConfirmationRequestedAt: Date?
627 627
     let completionContradictionPercent: Double?
628 628
     let selectedDataGroup: UInt8?
629
+    let trimStart: Date?
630
+    let trimEnd: Date?
629 631
     let checkpoints: [ChargeCheckpointSummary]
630 632
     let aggregatedSamples: [ChargeSessionSampleSummary]
631 633
 
634
+    var effectiveTrimStart: Date { trimStart ?? startedAt }
635
+    var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
636
+    var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
637
+    var effectiveTimeRange: ClosedRange<Date> {
638
+        let start = effectiveTrimStart
639
+        let end = max(effectiveTrimEnd, start)
640
+        return start...end
641
+    }
642
+    var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
643
+        guard isTrimmed else { return aggregatedSamples }
644
+        let range = effectiveTimeRange
645
+        return aggregatedSamples.filter { range.contains($0.timestamp) }
646
+    }
647
+
632 648
     var sessionKind: ChargeSessionKind {
633 649
         ChargeSessionKind(
634 650
             chargingTransportMode: chargingTransportMode,
@@ -651,7 +667,10 @@ struct ChargeSessionSummary: Identifiable, Hashable {
651 667
     }
652 668
 
653 669
     var effectiveDuration: TimeInterval {
654
-        meterObservedDuration ?? duration
670
+        if isTrimmed {
671
+            return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0)
672
+        }
673
+        return meterObservedDuration ?? duration
655 674
     }
656 675
 
657 676
     var effectiveOrMeasuredEnergyWh: Double {
@@ -1403,7 +1422,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1403 1422
                 Anchor(
1404 1423
                     percent: startBatteryPercent,
1405 1424
                     energyWh: 0,
1406
-                    timestamp: session.startedAt,
1425
+                    timestamp: session.effectiveTrimStart,
1407 1426
                     description: "session start",
1408 1427
                     isCheckpoint: false
1409 1428
                 )
+12 -5
USB Meter/Model/ChargeInsightsStore.swift
@@ -554,6 +554,8 @@ final class ChargeInsightsStore {
554 554
         }
555 555
 
556 556
         var didSave = false
557
+        var deviceIDToRefresh: String?
558
+
557 559
         context.performAndWait {
558 560
             guard let session = fetchSessionObject(id: sessionID.uuidString) else {
559 561
                 return
@@ -575,13 +577,18 @@ final class ChargeInsightsStore {
575 577
                 return
576 578
             }
577 579
 
578
-            if let deviceID = stringValue(session, key: "chargedDeviceID") {
579
-                refreshDerivedMetrics(forChargedDeviceID: deviceID)
580
-                didSave = saveContext()
581
-            } else {
582
-                didSave = true
580
+            didSave = true
581
+            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
582
+        }
583
+
584
+        if let deviceID = deviceIDToRefresh {
585
+            context.perform { [weak self] in
586
+                guard let self else { return }
587
+                self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
588
+                self.saveContext()
583 589
             }
584 590
         }
591
+
585 592
         return didSave
586 593
     }
587 594
 
+94 -0
USB Meter/Model/ChargingWindowDetector.swift
@@ -0,0 +1,94 @@
1
+//
2
+//  ChargingWindowDetector.swift
3
+//  USB Meter
4
+//
5
+
6
+import Foundation
7
+
8
+enum ChargingWindowDetector {
9
+
10
+    struct DetectedWindow {
11
+        let start: Date
12
+        let end: Date
13
+        // How much shorter the window is vs total session (0..1). Higher = more trimming needed.
14
+        let trimRatio: Double
15
+    }
16
+
17
+    // Power above this threshold counts as "charging activity" (Watts)
18
+    private static let activityThreshold = 0.05
19
+    // A charging segment must last at least this long to be considered real
20
+    private static let minimumSegmentDuration: TimeInterval = 3 * 60
21
+    // Gaps shorter than this between active segments are bridged (e.g. brief wireless drop)
22
+    private static let mergeGapDuration: TimeInterval = 120
23
+    // Padding added before first and after last sample of the detected window
24
+    private static let padding: TimeInterval = 30
25
+    // Only surface detection when active window is shorter than this fraction of total session
26
+    // e.g. 0.30 means: show banner if active charging < 70% of total session time
27
+    static let significantTrimThreshold = 0.30
28
+
29
+    static func detect(
30
+        samples: [ChargeSessionSampleSummary],
31
+        sessionStart: Date,
32
+        sessionEnd: Date
33
+    ) -> DetectedWindow? {
34
+        guard !samples.isEmpty else { return nil }
35
+        let sorted = samples.sorted { $0.timestamp < $1.timestamp }
36
+
37
+        // Build contiguous active segments
38
+        struct Segment { var start: Date; var end: Date }
39
+        var segments: [Segment] = []
40
+        var segStart: Date?
41
+        var segEnd: Date?
42
+
43
+        for sample in sorted {
44
+            if sample.averagePowerWatts >= activityThreshold {
45
+                if segStart == nil { segStart = sample.timestamp }
46
+                segEnd = sample.timestamp
47
+            } else {
48
+                if let s = segStart, let e = segEnd {
49
+                    segments.append(Segment(start: s, end: e))
50
+                }
51
+                segStart = nil
52
+                segEnd = nil
53
+            }
54
+        }
55
+        if let s = segStart, let e = segEnd {
56
+            segments.append(Segment(start: s, end: e))
57
+        }
58
+
59
+        guard !segments.isEmpty else { return nil }
60
+
61
+        // Merge segments separated by short gaps
62
+        var merged: [Segment] = [segments[0]]
63
+        for seg in segments.dropFirst() {
64
+            let gap = seg.start.timeIntervalSince(merged[merged.count - 1].end)
65
+            if gap <= mergeGapDuration {
66
+                merged[merged.count - 1].end = seg.end
67
+            } else {
68
+                merged.append(seg)
69
+            }
70
+        }
71
+
72
+        // Filter out short segments
73
+        let significant = merged.filter {
74
+            $0.end.timeIntervalSince($0.start) >= minimumSegmentDuration
75
+        }
76
+
77
+        guard !significant.isEmpty else { return nil }
78
+
79
+        // Pick primary segment: the longest one
80
+        let primary = significant.max { a, b in
81
+            a.end.timeIntervalSince(a.start) < b.end.timeIntervalSince(b.start)
82
+        }!
83
+
84
+        let windowStart = max(sessionStart, primary.start.addingTimeInterval(-padding))
85
+        let windowEnd   = min(sessionEnd,   primary.end.addingTimeInterval(padding))
86
+
87
+        let sessionDuration = sessionEnd.timeIntervalSince(sessionStart)
88
+        let windowDuration  = windowEnd.timeIntervalSince(windowStart)
89
+        guard sessionDuration > 0 else { return nil }
90
+
91
+        let trimRatio = 1.0 - (windowDuration / sessionDuration)
92
+        return DetectedWindow(start: windowStart, end: windowEnd, trimRatio: trimRatio)
93
+    }
94
+}
+117 -23
USB Meter/Model/Measurements.swift
@@ -312,14 +312,18 @@ class Measurements : ObservableObject {
312 312
     }
313 313
 
314 314
     func restorePersistedChargeSessionSamplesIfNeeded(
315
-        from session: ChargeSessionSummary
315
+        from session: ChargeSessionSummary,
316
+        replacingLiveBufferIfNeeded: Bool = false
316 317
     ) {
317
-        guard power.points.isEmpty,
318
-              voltage.points.isEmpty,
319
-              current.points.isEmpty,
320
-              temperature.points.isEmpty,
321
-              energy.points.isEmpty,
322
-              rssi.points.isEmpty else {
318
+        let hasExistingBuffer =
319
+            power.points.isEmpty == false ||
320
+            voltage.points.isEmpty == false ||
321
+            current.points.isEmpty == false ||
322
+            temperature.points.isEmpty == false ||
323
+            energy.points.isEmpty == false ||
324
+            rssi.points.isEmpty == false
325
+
326
+        guard hasExistingBuffer == false || replacingLiveBufferIfNeeded else {
323 327
             return
324 328
         }
325 329
 
@@ -332,24 +336,57 @@ class Measurements : ObservableObject {
332 336
 
333 337
         guard !sortedSamples.isEmpty else { return }
334 338
 
339
+        let preservedEnergyCounterValue = lastEnergyCounterValue
340
+        let preservedEnergyGroupID = lastEnergyGroupID
341
+        let persistedRangeUpperBound = sortedSamples.last?.timestamp
342
+        if hasExistingBuffer {
343
+            flushPendingValues()
344
+        }
345
+
335 346
         resetPendingAggregation()
336 347
 
337
-        power.replacePoints(restoredPoints(from: sortedSamples) { sample in
338
-            sample.averagePowerWatts
339
-        })
340
-        current.replacePoints(restoredPoints(from: sortedSamples) { sample in
341
-            sample.averageCurrentAmps
342
-        })
343
-        voltage.replacePoints(restoredPoints(from: sortedSamples) { sample in
344
-            sample.averageVoltageVolts
345
-        })
346
-        energy.replacePoints(restoredPoints(from: sortedSamples) { sample in
347
-            sample.measuredEnergyWh
348
-        })
349
-        temperature.resetSeries()
350
-        rssi.resetSeries()
351
-        lastEnergyCounterValue = nil
352
-        lastEnergyGroupID = nil
348
+        power.replacePoints(mergedRestoredPoints(
349
+            restored: restoredPoints(from: sortedSamples) { sample in
350
+                sample.averagePowerWatts
351
+            },
352
+            existing: power.points,
353
+            persistedRangeUpperBound: persistedRangeUpperBound
354
+        ))
355
+        current.replacePoints(mergedRestoredPoints(
356
+            restored: restoredPoints(from: sortedSamples) { sample in
357
+                sample.averageCurrentAmps
358
+            },
359
+            existing: current.points,
360
+            persistedRangeUpperBound: persistedRangeUpperBound
361
+        ))
362
+        voltage.replacePoints(mergedRestoredPoints(
363
+            restored: restoredPoints(from: sortedSamples) { sample in
364
+                sample.averageVoltageVolts
365
+            },
366
+            existing: voltage.points,
367
+            persistedRangeUpperBound: persistedRangeUpperBound
368
+        ))
369
+        energy.replacePoints(mergedRestoredPoints(
370
+            restored: restoredPoints(from: sortedSamples) { sample in
371
+                sample.measuredEnergyWh
372
+            },
373
+            existing: energy.points,
374
+            persistedRangeUpperBound: persistedRangeUpperBound
375
+        ))
376
+        temperature.replacePoints(
377
+            preservedTailPoints(
378
+                from: temperature.points,
379
+                after: persistedRangeUpperBound
380
+            )
381
+        )
382
+        rssi.replacePoints(
383
+            preservedTailPoints(
384
+                from: rssi.points,
385
+                after: persistedRangeUpperBound
386
+            )
387
+        )
388
+        lastEnergyCounterValue = hasExistingBuffer ? preservedEnergyCounterValue : nil
389
+        lastEnergyGroupID = hasExistingBuffer ? preservedEnergyGroupID : nil
353 390
         accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
354 391
         self.objectWillChange.send()
355 392
     }
@@ -390,6 +427,63 @@ class Measurements : ObservableObject {
390 427
         return restored
391 428
     }
392 429
 
430
+    private func mergedRestoredPoints(
431
+        restored: [Measurement.Point],
432
+        existing: [Measurement.Point],
433
+        persistedRangeUpperBound: Date?
434
+    ) -> [Measurement.Point] {
435
+        var merged = restored
436
+        let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound)
437
+
438
+        guard preservedTail.isEmpty == false else {
439
+            return merged
440
+        }
441
+
442
+        if let tailFirst = preservedTail.first,
443
+           tailFirst.isSample,
444
+           let lastRestoredSample = merged.last(where: \.isSample),
445
+           lastRestoredSample.timestamp < tailFirst.timestamp {
446
+            merged.append(
447
+                Measurement.Point(
448
+                    id: merged.count,
449
+                    timestamp: tailFirst.timestamp,
450
+                    value: merged.last?.value ?? tailFirst.value,
451
+                    kind: .discontinuity
452
+                )
453
+            )
454
+        }
455
+
456
+        merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
457
+            Measurement.Point(
458
+                id: merged.count + offset,
459
+                timestamp: point.timestamp,
460
+                value: point.value,
461
+                kind: point.kind
462
+            )
463
+        })
464
+        return merged
465
+    }
466
+
467
+    private func preservedTailPoints(
468
+        from existing: [Measurement.Point],
469
+        after persistedRangeUpperBound: Date?
470
+    ) -> [Measurement.Point] {
471
+        guard let persistedRangeUpperBound else {
472
+            return existing
473
+        }
474
+
475
+        let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
476
+        guard tail.isEmpty == false else {
477
+            return []
478
+        }
479
+
480
+        if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
481
+            return Array(tail[firstSampleIndex...])
482
+        }
483
+
484
+        return []
485
+    }
486
+
393 487
     func resetSeries() {
394 488
         power.resetSeries()
395 489
         voltage.resetSeries()
+12 -4
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -574,12 +574,13 @@ struct ChargedDeviceDetailView: View {
574 574
     }
575 575
 
576 576
     private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
577
+        let displayedSamples = session.displayedAggregatedSamples
577 578
         let currentSeries = storedSeriesSnapshot(
578
-            from: session.aggregatedSamples,
579
+            from: displayedSamples,
579 580
             minimumYSpan: 0.15
580 581
         ) { $0.averageCurrentAmps }
581 582
         let energySeries = storedSeriesSnapshot(
582
-            from: session.aggregatedSamples,
583
+            from: displayedSamples,
583 584
             minimumYSpan: 0.2
584 585
         ) { $0.measuredEnergyWh }
585 586
 
@@ -594,14 +595,18 @@ struct ChargedDeviceDetailView: View {
594 595
                             message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress."
595 596
                         )
596 597
                     }
597
-                    Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
598
+                    Text(session.isTrimmed
599
+                         ? "Showing the saved trim window at aggregated resolution."
600
+                         : (session.status.isOpen
601
+                            ? "Open session, persisted as aggregated samples."
602
+                            : "Most recent persisted session at aggregated resolution."))
598 603
                         .font(.caption)
599 604
                         .foregroundColor(.secondary)
600 605
                 }
601 606
 
602 607
                 Spacer()
603 608
 
604
-                Text("\(session.aggregatedSamples.count) points")
609
+                Text("\(displayedSamples.count) points")
605 610
                     .font(.caption.weight(.semibold))
606 611
                     .foregroundColor(.secondary)
607 612
             }
@@ -787,6 +792,9 @@ struct ChargedDeviceDetailView: View {
787 792
         if chargedDevice?.shouldShowChargingStateMode(session.chargingStateMode) != false {
788 793
             components.append(session.chargingStateMode.title)
789 794
         }
795
+        if session.isTrimmed {
796
+            components.append("Trimmed")
797
+        }
790 798
         components.append(session.sourceMode.title)
791 799
         return components.joined(separator: " • ")
792 800
     }
+115 -38
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -13,6 +13,34 @@ private enum PresentTrackingMode: CaseIterable, Hashable {
13 13
     case keepStartTimestamp
14 14
 }
15 15
 
16
+enum MeasurementChartSelectorActionTone {
17
+    case reversible
18
+    case destructive
19
+    case destructiveProminent
20
+}
21
+
22
+struct MeasurementChartSelectionAction {
23
+    let title: String
24
+    let systemName: String
25
+    let tone: MeasurementChartSelectorActionTone
26
+    let handler: (ClosedRange<Date>) -> Void
27
+}
28
+
29
+struct MeasurementChartResetAction {
30
+    let title: String
31
+    let systemName: String
32
+    let tone: MeasurementChartSelectorActionTone
33
+    let confirmationTitle: String
34
+    let confirmationButtonTitle: String
35
+    let handler: () -> Void
36
+}
37
+
38
+struct MeasurementChartRangeSelectorConfiguration {
39
+    let keepAction: MeasurementChartSelectionAction
40
+    let removeAction: MeasurementChartSelectionAction?
41
+    let resetAction: MeasurementChartResetAction
42
+}
43
+
16 44
 struct MeasurementChartView: View {
17 45
     private enum SmoothingLevel: CaseIterable, Hashable {
18 46
         case off
@@ -99,11 +127,15 @@ struct MeasurementChartView: View {
99 127
     let availableSize: CGSize
100 128
     let showsRangeSelector: Bool
101 129
     let rebasesEnergyToVisibleRangeStart: Bool
130
+    let extendsTimelineToPresent: Bool
131
+    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
102 132
     
103 133
     @EnvironmentObject private var measurements: Measurements
104 134
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
105 135
     @Environment(\.verticalSizeClass) private var verticalSizeClass
106 136
     var timeRange: ClosedRange<Date>? = nil
137
+    let timeRangeLowerBound: Date?
138
+    let timeRangeUpperBound: Date?
107 139
     
108 140
     @State var displayVoltage: Bool = false
109 141
     @State var displayCurrent: Bool = false
@@ -131,14 +163,22 @@ struct MeasurementChartView: View {
131 163
         compactLayout: Bool = false,
132 164
         availableSize: CGSize = .zero,
133 165
         timeRange: ClosedRange<Date>? = nil,
166
+        timeRangeLowerBound: Date? = nil,
167
+        timeRangeUpperBound: Date? = nil,
134 168
         showsRangeSelector: Bool = true,
135
-        rebasesEnergyToVisibleRangeStart: Bool = false
169
+        rebasesEnergyToVisibleRangeStart: Bool = false,
170
+        extendsTimelineToPresent: Bool = true,
171
+        rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
136 172
     ) {
137 173
         self.compactLayout = compactLayout
138 174
         self.availableSize = availableSize
139 175
         self.timeRange = timeRange
176
+        self.timeRangeLowerBound = timeRangeLowerBound
177
+        self.timeRangeUpperBound = timeRangeUpperBound
140 178
         self.showsRangeSelector = showsRangeSelector
141 179
         self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
180
+        self.extendsTimelineToPresent = extendsTimelineToPresent
181
+        self.rangeSelectorConfiguration = rangeSelectorConfiguration
142 182
     }
143 183
 
144 184
     private var axisColumnWidth: CGFloat {
@@ -399,9 +439,7 @@ struct MeasurementChartView: View {
399 439
                                     selectorTint: selectorTint,
400 440
                                     compactLayout: compactLayout,
401 441
                                     minimumSelectionSpan: minimumTimeSpan,
402
-                                    onKeepSelection: trimBufferToSelection,
403
-                                    onRemoveSelection: removeSelectionFromBuffer,
404
-                                    onResetBuffer: resetBuffer,
442
+                                    configuration: resolvedRangeSelectorConfiguration(),
405 443
                                     selectedTimeRange: $selectedVisibleTimeRange,
406 444
                                     isPinnedToPresent: $isPinnedToPresent,
407 445
                                     presentTrackingMode: $presentTrackingMode
@@ -423,7 +461,7 @@ struct MeasurementChartView: View {
423 461
         .font(chartBaseFont)
424 462
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
425 463
         .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
426
-            guard timeRange == nil else { return }
464
+            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
427 465
             chartNow = now
428 466
         }
429 467
     }
@@ -747,6 +785,35 @@ struct MeasurementChartView: View {
747 785
         measurements.resetSeries()
748 786
     }
749 787
 
788
+    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
789
+        if let rangeSelectorConfiguration {
790
+            return rangeSelectorConfiguration
791
+        }
792
+
793
+        return MeasurementChartRangeSelectorConfiguration(
794
+            keepAction: MeasurementChartSelectionAction(
795
+                title: compactLayout ? "Keep" : "Keep Selection",
796
+                systemName: "scissors",
797
+                tone: .destructive,
798
+                handler: trimBufferToSelection
799
+            ),
800
+            removeAction: MeasurementChartSelectionAction(
801
+                title: compactLayout ? "Cut" : "Remove Selection",
802
+                systemName: "minus.circle",
803
+                tone: .destructive,
804
+                handler: removeSelectionFromBuffer
805
+            ),
806
+            resetAction: MeasurementChartResetAction(
807
+                title: compactLayout ? "Reset" : "Reset Buffer",
808
+                systemName: "trash",
809
+                tone: .destructiveProminent,
810
+                confirmationTitle: "Reset captured measurements?",
811
+                confirmationButtonTitle: "Reset buffer",
812
+                handler: resetBuffer
813
+            )
814
+        )
815
+    }
816
+
750 817
     private func seriesToggleFont(condensedLayout: Bool) -> Font {
751 818
         if isLargeDisplay {
752 819
             return .body.weight(.semibold)
@@ -1320,11 +1387,19 @@ struct MeasurementChartView: View {
1320 1387
         }
1321 1388
 
1322 1389
         let samplePoints = timelineSamplePoints()
1323
-        guard let lowerBound = samplePoints.first?.timestamp else {
1390
+        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1391
+        guard let lowerBound else {
1324 1392
             return nil
1325 1393
         }
1326 1394
 
1327
-        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1395
+        let latestSampleTimestamp = samplePoints.last?.timestamp
1396
+        let resolvedUpperBound = timeRangeUpperBound ?? {
1397
+            guard extendsTimelineToPresent else {
1398
+                return latestSampleTimestamp ?? lowerBound
1399
+            }
1400
+            return max(latestSampleTimestamp ?? chartNow, chartNow)
1401
+        }()
1402
+        let upperBound = max(resolvedUpperBound, lowerBound)
1328 1403
         return normalizedTimeRange(lowerBound...upperBound)
1329 1404
     }
1330 1405
 
@@ -1903,12 +1978,6 @@ private struct TimeRangeSelectorView: View {
1903 1978
         case window
1904 1979
     }
1905 1980
 
1906
-    private enum ActionTone {
1907
-        case reversible
1908
-        case destructive
1909
-        case destructiveProminent
1910
-    }
1911
-
1912 1981
     private struct DragState {
1913 1982
         let target: DragTarget
1914 1983
         let initialRange: ClosedRange<Date>
@@ -1920,9 +1989,7 @@ private struct TimeRangeSelectorView: View {
1920 1989
     let selectorTint: Color
1921 1990
     let compactLayout: Bool
1922 1991
     let minimumSelectionSpan: TimeInterval
1923
-    let onKeepSelection: (ClosedRange<Date>) -> Void
1924
-    let onRemoveSelection: (ClosedRange<Date>) -> Void
1925
-    let onResetBuffer: () -> Void
1992
+    let configuration: MeasurementChartRangeSelectorConfiguration
1926 1993
 
1927 1994
     @Binding var selectedTimeRange: ClosedRange<Date>?
1928 1995
     @Binding var isPinnedToPresent: Bool
@@ -1985,38 +2052,43 @@ private struct TimeRangeSelectorView: View {
1985 2052
             HStack(spacing: 8) {
1986 2053
                 if !coversFullRange {
1987 2054
                     actionButton(
1988
-                        title: compactLayout ? "Keep" : "Keep Selection",
1989
-                        systemName: "scissors",
1990
-                        tone: .destructive,
2055
+                        title: configuration.keepAction.title,
2056
+                        systemName: configuration.keepAction.systemName,
2057
+                        tone: configuration.keepAction.tone,
1991 2058
                         action: {
1992
-                            onKeepSelection(currentRange)
2059
+                            configuration.keepAction.handler(currentRange)
2060
+                            resetSelectionState()
1993 2061
                         }
1994 2062
                     )
1995 2063
 
1996
-                    actionButton(
1997
-                        title: compactLayout ? "Cut" : "Remove Selection",
1998
-                        systemName: "minus.circle",
1999
-                        tone: .destructive,
2000
-                        action: {
2001
-                            onRemoveSelection(currentRange)
2002
-                        }
2003
-                    )
2064
+                    if let removeAction = configuration.removeAction {
2065
+                        actionButton(
2066
+                            title: removeAction.title,
2067
+                            systemName: removeAction.systemName,
2068
+                            tone: removeAction.tone,
2069
+                            action: {
2070
+                                removeAction.handler(currentRange)
2071
+                                resetSelectionState()
2072
+                            }
2073
+                        )
2074
+                    }
2004 2075
                 }
2005 2076
 
2006 2077
                 Spacer(minLength: 0)
2007 2078
 
2008 2079
                 actionButton(
2009
-                    title: compactLayout ? "Reset" : "Reset Buffer",
2010
-                    systemName: "trash",
2011
-                    tone: .destructiveProminent,
2080
+                    title: configuration.resetAction.title,
2081
+                    systemName: configuration.resetAction.systemName,
2082
+                    tone: configuration.resetAction.tone,
2012 2083
                     action: {
2013 2084
                         showResetConfirmation = true
2014 2085
                     }
2015 2086
                 )
2016 2087
             }
2017
-            .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
2018
-                Button("Reset buffer", role: .destructive) {
2019
-                    onResetBuffer()
2088
+            .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2089
+                Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2090
+                    configuration.resetAction.handler()
2091
+                    resetSelectionState()
2020 2092
                 }
2021 2093
                 Button("Cancel", role: .cancel) {}
2022 2094
             }
@@ -2164,7 +2236,7 @@ private struct TimeRangeSelectorView: View {
2164 2236
     private func actionButton(
2165 2237
         title: String,
2166 2238
         systemName: String,
2167
-        tone: ActionTone,
2239
+        tone: MeasurementChartSelectorActionTone,
2168 2240
         action: @escaping () -> Void
2169 2241
     ) -> some View {
2170 2242
         let foregroundColor: Color = {
@@ -2194,7 +2266,7 @@ private struct TimeRangeSelectorView: View {
2194 2266
         )
2195 2267
     }
2196 2268
 
2197
-    private func toneColor(for tone: ActionTone) -> Color {
2269
+    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
2198 2270
         switch tone {
2199 2271
         case .reversible:
2200 2272
             return selectorTint
@@ -2203,7 +2275,7 @@ private struct TimeRangeSelectorView: View {
2203 2275
         }
2204 2276
     }
2205 2277
 
2206
-    private func actionButtonBackground(for tone: ActionTone) -> Color {
2278
+    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
2207 2279
         switch tone {
2208 2280
         case .reversible:
2209 2281
             return selectorTint.opacity(0.12)
@@ -2214,7 +2286,7 @@ private struct TimeRangeSelectorView: View {
2214 2286
         }
2215 2287
     }
2216 2288
 
2217
-    private func actionButtonBorder(for tone: ActionTone) -> Color {
2289
+    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
2218 2290
         switch tone {
2219 2291
         case .reversible:
2220 2292
             return selectorTint.opacity(0.22)
@@ -2529,6 +2601,11 @@ private struct TimeRangeSelectorView: View {
2529 2601
         isPinnedToPresent = pinToPresent
2530 2602
     }
2531 2603
 
2604
+    private func resetSelectionState() {
2605
+        selectedTimeRange = nil
2606
+        isPinnedToPresent = false
2607
+    }
2608
+
2532 2609
     private func selectionTouchesPresent(
2533 2610
         _ range: ClosedRange<Date>
2534 2611
     ) -> Bool {
+378 -0
USB Meter/Views/Meter/Tabs/ChargeRecord/SessionTrimEditorView.swift
@@ -0,0 +1,378 @@
1
+//
2
+//  SessionTrimEditorView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SessionTrimEditorView: View {
9
+
10
+    let session: ChargeSessionSummary
11
+    let liveTimeRange: ClosedRange<Date>?
12
+    let onApply: (Date?, Date?) -> Void
13
+    let onDismiss: () -> Void
14
+
15
+    @State private var trimStart: Date
16
+    @State private var trimEnd: Date
17
+
18
+    private var fullStart: Date { liveTimeRange?.lowerBound ?? session.startedAt }
19
+    private var fullEnd: Date   { liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) }
20
+    private var sessionDuration: TimeInterval { max(fullEnd.timeIntervalSince(fullStart), 1) }
21
+
22
+    private var startFraction: Double {
23
+        trimStart.timeIntervalSince(fullStart) / sessionDuration
24
+    }
25
+    private var endFraction: Double {
26
+        trimEnd.timeIntervalSince(fullStart) / sessionDuration
27
+    }
28
+
29
+    // Energy preview from cumulative sample values
30
+    private var previewEnergyWh: Double {
31
+        let sorted = session.aggregatedSamples.sorted { $0.timestamp < $1.timestamp }
32
+        let baseline = sorted.last { $0.timestamp <= trimStart }
33
+        guard let endSample = sorted.last(where: { $0.timestamp <= trimEnd }) else { return 0 }
34
+        return max(endSample.measuredEnergyWh - (baseline?.measuredEnergyWh ?? 0), 0)
35
+    }
36
+
37
+    private var trimmedDuration: TimeInterval {
38
+        max(trimEnd.timeIntervalSince(trimStart), 0)
39
+    }
40
+
41
+    private var checkpointsToRemove: [ChargeCheckpointSummary] {
42
+        session.checkpoints.filter { $0.timestamp < trimStart || $0.timestamp > trimEnd }
43
+    }
44
+
45
+    private var isModified: Bool {
46
+        trimStart != (session.trimStart ?? fullStart) ||
47
+        trimEnd   != (session.trimEnd   ?? fullEnd)
48
+    }
49
+
50
+    init(
51
+        session: ChargeSessionSummary,
52
+        detectedWindow: ChargingWindowDetector.DetectedWindow? = nil,
53
+        liveTimeRange: ClosedRange<Date>? = nil,
54
+        onApply: @escaping (Date?, Date?) -> Void,
55
+        onDismiss: @escaping () -> Void
56
+    ) {
57
+        self.session   = session
58
+        self.liveTimeRange = liveTimeRange
59
+        self.onApply   = onApply
60
+        self.onDismiss = onDismiss
61
+
62
+        let fullStart = liveTimeRange?.lowerBound ?? session.startedAt
63
+        let fullEnd = liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt)
64
+        let start = session.trimStart
65
+            ?? detectedWindow?.start
66
+            ?? fullStart
67
+        let end = session.trimEnd
68
+            ?? detectedWindow?.end
69
+            ?? fullEnd
70
+
71
+        _trimStart = State(initialValue: start)
72
+        _trimEnd   = State(initialValue: end)
73
+    }
74
+
75
+    var body: some View {
76
+        VStack(spacing: 0) {
77
+            header
78
+            ScrollView {
79
+                VStack(spacing: 16) {
80
+                    chartWithHandles
81
+                    rangeControls
82
+                    previewMetrics
83
+                    if !checkpointsToRemove.isEmpty {
84
+                        checkpointWarning
85
+                    }
86
+                }
87
+                .padding(16)
88
+            }
89
+            applyBar
90
+        }
91
+        .background(Color(.systemGroupedBackground).ignoresSafeArea())
92
+    }
93
+
94
+    // MARK: - Header
95
+
96
+    private var header: some View {
97
+        HStack {
98
+            Button("Cancel", action: onDismiss)
99
+                .foregroundColor(.secondary)
100
+            Spacer()
101
+            Text("Trim Session")
102
+                .font(.headline)
103
+            Spacer()
104
+            Button("Reset") {
105
+                withAnimation(.spring(response: 0.3)) {
106
+                    trimStart = fullStart
107
+                    trimEnd   = fullEnd
108
+                }
109
+            }
110
+            .foregroundColor(.orange)
111
+            .disabled(trimStart == fullStart && trimEnd == fullEnd)
112
+        }
113
+        .padding(.horizontal, 18)
114
+        .padding(.vertical, 14)
115
+        .background(.regularMaterial)
116
+    }
117
+
118
+    // MARK: - Chart with trim overlay
119
+
120
+    private var chartWithHandles: some View {
121
+        VStack(alignment: .leading, spacing: 10) {
122
+            HStack(spacing: 6) {
123
+                Image(systemName: "scissors")
124
+                    .foregroundColor(.blue)
125
+                Text("Session Window")
126
+                    .font(.headline)
127
+            }
128
+
129
+            GeometryReader { geo in
130
+                let chartW = geo.size.width
131
+                ZStack(alignment: .topLeading) {
132
+                    // Background chart — full session
133
+                    MeasurementChartView(
134
+                        compactLayout: true,
135
+                        availableSize: geo.size,
136
+                        timeRange: fullStart...fullEnd,
137
+                        showsRangeSelector: false,
138
+                        rebasesEnergyToVisibleRangeStart: false
139
+                    )
140
+
141
+                    // Dimmed region before trimStart
142
+                    Rectangle()
143
+                        .fill(Color.black.opacity(0.35))
144
+                        .frame(width: max(startFraction * chartW, 0))
145
+                        .allowsHitTesting(false)
146
+
147
+                    // Dimmed region after trimEnd
148
+                    let endX = endFraction * chartW
149
+                    Rectangle()
150
+                        .fill(Color.black.opacity(0.35))
151
+                        .frame(width: max(chartW - endX, 0))
152
+                        .offset(x: endX)
153
+                        .allowsHitTesting(false)
154
+
155
+                    // Start handle
156
+                    trimHandle(
157
+                        color: .green,
158
+                        symbol: "arrow.right.to.line",
159
+                        xFraction: startFraction,
160
+                        chartWidth: chartW,
161
+                        onDrag: { dx in
162
+                            let newFrac = max(0, min(startFraction + dx / chartW, endFraction - 0.01))
163
+                            trimStart = fullStart.addingTimeInterval(newFrac * sessionDuration)
164
+                        }
165
+                    )
166
+
167
+                    // End handle
168
+                    trimHandle(
169
+                        color: .red,
170
+                        symbol: "arrow.left.to.line",
171
+                        xFraction: endFraction,
172
+                        chartWidth: chartW,
173
+                        onDrag: { dx in
174
+                            let newFrac = min(1, max(endFraction + dx / chartW, startFraction + 0.01))
175
+                            trimEnd = fullStart.addingTimeInterval(newFrac * sessionDuration)
176
+                        }
177
+                    )
178
+                }
179
+                .clipped()
180
+            }
181
+            .frame(height: 260)
182
+        }
183
+        .padding(16)
184
+        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
185
+    }
186
+
187
+    @ViewBuilder
188
+    private func trimHandle(
189
+        color: Color,
190
+        symbol: String,
191
+        xFraction: Double,
192
+        chartWidth: CGFloat,
193
+        onDrag: @escaping (CGFloat) -> Void
194
+    ) -> some View {
195
+        let xPos = CGFloat(xFraction) * chartWidth
196
+
197
+        ZStack(alignment: .top) {
198
+            // Vertical line
199
+            Rectangle()
200
+                .fill(color)
201
+                .frame(width: 2)
202
+                .frame(maxHeight: .infinity)
203
+                .offset(x: xPos - 1)
204
+                .allowsHitTesting(false)
205
+
206
+            // Drag knob
207
+            Circle()
208
+                .fill(color)
209
+                .frame(width: 28, height: 28)
210
+                .overlay(
211
+                    Image(systemName: symbol)
212
+                        .font(.system(size: 11, weight: .bold))
213
+                        .foregroundColor(.white)
214
+                )
215
+                .shadow(radius: 3)
216
+                .offset(x: xPos - 14)
217
+                .gesture(
218
+                    DragGesture(minimumDistance: 0, coordinateSpace: .local)
219
+                        .onChanged { value in
220
+                            onDrag(value.translation.width)
221
+                        }
222
+                )
223
+        }
224
+    }
225
+
226
+    // MARK: - Range controls
227
+
228
+    private var rangeControls: some View {
229
+        VStack(spacing: 12) {
230
+            rangeRow(
231
+                label: "Start",
232
+                color: .green,
233
+                symbol: "arrow.right.to.line",
234
+                date: $trimStart,
235
+                sliderValue: Binding(
236
+                    get: { startFraction },
237
+                    set: { v in
238
+                        let clamped = max(0, min(v, endFraction - 0.01))
239
+                        trimStart = fullStart.addingTimeInterval(clamped * sessionDuration)
240
+                    }
241
+                )
242
+            )
243
+            rangeRow(
244
+                label: "End",
245
+                color: .red,
246
+                symbol: "arrow.left.to.line",
247
+                date: $trimEnd,
248
+                sliderValue: Binding(
249
+                    get: { endFraction },
250
+                    set: { v in
251
+                        let clamped = min(1, max(v, startFraction + 0.01))
252
+                        trimEnd = fullStart.addingTimeInterval(clamped * sessionDuration)
253
+                    }
254
+                )
255
+            )
256
+        }
257
+        .padding(16)
258
+        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
259
+    }
260
+
261
+    private func rangeRow(
262
+        label: String,
263
+        color: Color,
264
+        symbol: String,
265
+        date: Binding<Date>,
266
+        sliderValue: Binding<Double>
267
+    ) -> some View {
268
+        VStack(spacing: 6) {
269
+            HStack {
270
+                Image(systemName: symbol)
271
+                    .foregroundColor(color)
272
+                    .frame(width: 20)
273
+                Text(label)
274
+                    .font(.subheadline.weight(.semibold))
275
+                Spacer()
276
+                Text(date.wrappedValue.format())
277
+                    .font(.caption.monospacedDigit())
278
+                    .foregroundColor(.secondary)
279
+            }
280
+            Slider(value: sliderValue, in: 0...1)
281
+                .tint(color)
282
+        }
283
+    }
284
+
285
+    // MARK: - Preview metrics
286
+
287
+    private var previewMetrics: some View {
288
+        VStack(alignment: .leading, spacing: 12) {
289
+            HStack(spacing: 6) {
290
+                Image(systemName: "waveform.path.ecg")
291
+                    .foregroundColor(.teal)
292
+                Text("Trimmed Metrics")
293
+                    .font(.headline)
294
+            }
295
+
296
+            let columns = [GridItem(.flexible()), GridItem(.flexible())]
297
+            LazyVGrid(columns: columns, spacing: 8) {
298
+                previewCell(label: "Energy", value: "\(previewEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
299
+                previewCell(label: "Duration", value: formatDuration(trimmedDuration), tint: .teal)
300
+            }
301
+        }
302
+        .padding(16)
303
+        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
304
+    }
305
+
306
+    private func previewCell(label: String, value: String, tint: Color) -> some View {
307
+        VStack(alignment: .leading, spacing: 4) {
308
+            Text(label)
309
+                .font(.caption)
310
+                .foregroundColor(.secondary)
311
+            Text(value)
312
+                .font(.system(.subheadline, design: .rounded).weight(.semibold))
313
+                .foregroundColor(tint)
314
+                .monospacedDigit()
315
+        }
316
+        .frame(maxWidth: .infinity, alignment: .leading)
317
+        .padding(10)
318
+        .background(RoundedRectangle(cornerRadius: 10).fill(tint.opacity(0.10)))
319
+    }
320
+
321
+    // MARK: - Checkpoint warning
322
+
323
+    private var checkpointWarning: some View {
324
+        HStack(alignment: .top, spacing: 10) {
325
+            Image(systemName: "exclamationmark.triangle.fill")
326
+                .foregroundColor(.orange)
327
+            VStack(alignment: .leading, spacing: 3) {
328
+                Text("\(checkpointsToRemove.count) checkpoint\(checkpointsToRemove.count == 1 ? "" : "s") outside the selected window will be removed.")
329
+                    .font(.subheadline)
330
+                ForEach(checkpointsToRemove) { cp in
331
+                    Text("• \(cp.timestamp.format()) — \(cp.batteryPercent.format(decimalDigits: 0))%")
332
+                        .font(.caption)
333
+                        .foregroundColor(.secondary)
334
+                }
335
+            }
336
+        }
337
+        .padding(14)
338
+        .background(
339
+            RoundedRectangle(cornerRadius: 12)
340
+                .fill(Color.orange.opacity(0.12))
341
+                .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.orange.opacity(0.25), lineWidth: 1))
342
+        )
343
+    }
344
+
345
+    // MARK: - Apply bar
346
+
347
+    private var applyBar: some View {
348
+        VStack(spacing: 0) {
349
+            Divider()
350
+            Button {
351
+                let newStart = trimStart == fullStart ? nil : trimStart
352
+                let newEnd   = trimEnd   == fullEnd   ? nil : trimEnd
353
+                onApply(newStart, newEnd)
354
+            } label: {
355
+                Label("Apply Trim", systemImage: "scissors")
356
+                    .font(.body.weight(.semibold))
357
+                    .frame(maxWidth: .infinity)
358
+                    .padding(.vertical, 14)
359
+            }
360
+            .buttonStyle(.borderedProminent)
361
+            .tint(.blue)
362
+            .disabled(!isModified)
363
+            .padding(16)
364
+        }
365
+        .background(.regularMaterial)
366
+    }
367
+
368
+    // MARK: - Helpers
369
+
370
+    private func formatDuration(_ duration: TimeInterval) -> String {
371
+        let totalSeconds = Int(duration.rounded(.down))
372
+        let hours = totalSeconds / 3600
373
+        let minutes = (totalSeconds % 3600) / 60
374
+        let seconds = totalSeconds % 60
375
+        if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) }
376
+        return String(format: "%02d:%02d", minutes, seconds)
377
+    }
378
+}