Showing 4 changed files with 721 additions and 165 deletions
+8 -0
USB Meter.xcodeproj/project.pbxproj
@@ -63,6 +63,8 @@
63 63
 		C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */; };
64 64
 		C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */; };
65 65
 		C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */; };
66
+		C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */; };
67
+		C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */; };
66 68
 		C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */; };
67 69
 		C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */; };
68 70
 		C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
@@ -184,6 +186,8 @@
184 186
 		C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorSheetView.swift; sourceTree = "<group>"; };
185 187
 		C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibrarySheetView.swift; sourceTree = "<group>"; };
186 188
 		C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailView.swift; sourceTree = "<group>"; };
189
+		C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionsView.swift; sourceTree = "<group>"; };
190
+		C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionDetailView.swift; sourceTree = "<group>"; };
187 191
 		C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionCompletionSheetView.swift; sourceTree = "<group>"; };
188 192
 		C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDevicesSectionView.swift; sourceTree = "<group>"; };
189 193
 		C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCheckpointEditorSheetView.swift; sourceTree = "<group>"; };
@@ -500,6 +504,8 @@
500 504
 			isa = PBXGroup;
501 505
 			children = (
502 506
 				C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */,
507
+				C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */,
508
+				C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */,
503 509
 				C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */,
504 510
 				C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */,
505 511
 				C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */,
@@ -804,6 +810,8 @@
804 810
 				C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */,
805 811
 				C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */,
806 812
 				C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */,
813
+				C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */,
814
+				C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */,
807 815
 				C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */,
808 816
 				C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */,
809 817
 				C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */,
+23 -165
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -12,7 +12,6 @@ struct ChargedDeviceDetailView: View {
12 12
     @Environment(\.dismiss) private var dismiss
13 13
     @State private var editorVisibility = false
14 14
     @State private var targetNotificationEditorVisibility = false
15
-    @State private var pendingSessionDeletion: ChargeSessionSummary?
16 15
     @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
17 16
     @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
18 17
     @State private var deleteConfirmationVisibility = false
@@ -47,8 +46,8 @@ struct ChargedDeviceDetailView: View {
47 46
                             typicalCurveCard(chargedDevice)
48 47
                         }
49 48
 
50
-                        if !chargedDevice.sessions.isEmpty {
51
-                            sessionsCard(chargedDevice)
49
+                        if !closedSessions(for: chargedDevice).isEmpty {
50
+                            sessionHistorySummaryCard(chargedDevice)
52 51
                         }
53 52
                     }
54 53
                     .padding()
@@ -114,16 +113,6 @@ struct ChargedDeviceDetailView: View {
114 113
             )
115 114
             .environmentObject(appData)
116 115
         }
117
-        .alert(item: $pendingSessionDeletion) { session in
118
-            Alert(
119
-                title: Text("Delete Session?"),
120
-                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
121
-                primaryButton: .destructive(Text("Delete")) {
122
-                    _ = appData.deleteChargeSession(sessionID: session.id)
123
-                },
124
-                secondaryButton: .cancel()
125
-            )
126
-        }
127 116
         .alert(item: $pendingCheckpointDeletion) { checkpoint in
128 117
             Alert(
129 118
                 title: Text("Delete Battery Checkpoint"),
@@ -661,142 +650,33 @@ struct ChargedDeviceDetailView: View {
661 650
         .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
662 651
     }
663 652
 
664
-    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
665
-        VStack(alignment: .leading, spacing: 12) {
666
-            HStack(spacing: 8) {
667
-                Text("Charge Sessions")
668
-                    .font(.headline)
669
-                ContextInfoButton(
670
-                    title: "Charge Sessions",
671
-                    message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
672
-                )
673
-            }
674
-
675
-            ForEach(chargedDevice.sessions, id: \.id) { session in
676
-                VStack(alignment: .leading, spacing: 6) {
677
-                    HStack(alignment: .firstTextBaseline, spacing: 10) {
678
-                        Text(session.startedAt.format())
679
-                            .font(.caption.weight(.semibold))
680
-                        Text(session.status.title)
681
-                            .font(.caption2.weight(.semibold))
682
-                            .padding(.horizontal, 8)
683
-                            .padding(.vertical, 4)
684
-                            .background(
685
-                                Capsule()
686
-                                    .fill(statusTint(for: session).opacity(0.16))
687
-                            )
688
-                        Spacer()
689
-                        Button {
690
-                            pendingSessionDeletion = session
691
-                        } label: {
692
-                            Image(systemName: "trash")
693
-                                .font(.caption.weight(.semibold))
694
-                                .foregroundColor(.red)
695
-                                .padding(8)
696
-                                .background(
697
-                                    Circle()
698
-                                        .fill(Color.red.opacity(0.10))
699
-                                )
700
-                        }
701
-                        .buttonStyle(.plain)
702
-                    }
653
+    private func sessionHistorySummaryCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
654
+        let sessions = closedSessions(for: chargedDevice)
655
+        let latestSession = sessions.first
656
+        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
703 657
 
704
-                    Text(sessionSummaryLine(session))
705
-                        .font(.caption2)
706
-                        .foregroundColor(.secondary)
658
+        return MeterInfoCardView(title: "Session History", tint: .teal) {
659
+            MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)")
660
+            MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
661
+            if let latestSession {
662
+                MeterInfoRowView(label: "Latest", value: latestSession.startedAt.format())
663
+            }
707 664
 
708
-                    MeterInfoRowView(
709
-                        label: "Duration",
710
-                        value: sessionDurationText(session)
711
-                    )
712
-                    MeterInfoRowView(
713
-                        label: "Energy",
714
-                        value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh"
715
-                    )
716
-                    if session.chargingTransportMode == .wireless,
717
-                       let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
718
-                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
719
-                        MeterInfoRowView(
720
-                            label: "Charger Energy",
721
-                            value: "\(session.measuredEnergyWh.format(decimalDigits: 2)) Wh"
722
-                        )
723
-                    }
724
-                    if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
725
-                        MeterInfoRowView(
726
-                            label: "Max Current",
727
-                            value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A"
728
-                        )
729
-                    }
730
-                    if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
731
-                        MeterInfoRowView(
732
-                            label: "Max Power",
733
-                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W"
734
-                        )
735
-                    }
736
-                    if session.chargingTransportMode == .wired,
737
-                       let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
738
-                        MeterInfoRowView(
739
-                            label: "Max Voltage",
740
-                            value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V"
741
-                        )
742
-                    }
743
-                    if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
744
-                        MeterInfoRowView(
745
-                            label: "Selected Voltage",
746
-                            value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
747
-                        )
748
-                    }
749
-                    if chargedDevice.isCharger == false,
750
-                       let chargerID = session.chargerID,
751
-                       let charger = appData.chargedDeviceSummary(id: chargerID) {
752
-                        MeterInfoRowView(
753
-                            label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger",
754
-                            value: charger.name
755
-                        )
756
-                    }
757
-                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
758
-                        Text(wirelessSessionHint)
759
-                            .font(.caption2)
760
-                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
761
-                    }
762
-                }
763
-                .padding(14)
764
-                .meterCard(
765
-                    tint: statusTint(for: session),
766
-                    fillOpacity: 0.10,
767
-                    strokeOpacity: 0.16,
768
-                    cornerRadius: 16
769
-                )
665
+            NavigationLink(
666
+                destination: ChargedDeviceSessionsView(chargedDeviceID: chargedDevice.id)
667
+            ) {
668
+                Label("Manage Sessions", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
669
+                    .font(.subheadline.weight(.semibold))
670
+                    .frame(maxWidth: .infinity)
671
+                    .padding(.vertical, 10)
672
+                    .meterCard(tint: .teal, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
770 673
             }
674
+            .buttonStyle(.plain)
771 675
         }
772
-        .frame(maxWidth: .infinity, alignment: .leading)
773
-        .padding(18)
774
-        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
775 676
     }
776 677
 
777
-    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
778
-        var components: [String] = []
779
-        let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID)
780
-
781
-        if let batteryDeltaPercent = session.batteryDeltaPercent {
782
-            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
783
-        }
784
-
785
-        if let capacityEstimateWh = session.capacityEstimateWh {
786
-            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
787
-        }
788
-
789
-        if chargedDevice?.shouldShowChargingTransport(session.chargingTransportMode) != false {
790
-            components.append(session.chargingTransportMode.title)
791
-        }
792
-        if chargedDevice?.shouldShowChargingStateMode(session.chargingStateMode) != false {
793
-            components.append(session.chargingStateMode.title)
794
-        }
795
-        if session.isTrimmed {
796
-            components.append("Trimmed")
797
-        }
798
-        components.append(session.sourceMode.title)
799
-        return components.joined(separator: " • ")
678
+    private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
679
+        chargedDevice.sessions.filter { !$0.status.isOpen }
800 680
     }
801 681
 
802 682
     private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
@@ -818,28 +698,6 @@ struct ChargedDeviceDetailView: View {
818 698
         return components.isEmpty ? nil : components.joined(separator: " • ")
819 699
     }
820 700
 
821
-    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
822
-        let formatter = DateComponentsFormatter()
823
-        let effectiveDuration = max(session.effectiveDuration, 0)
824
-        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
825
-        formatter.unitsStyle = .abbreviated
826
-        formatter.zeroFormattingBehavior = .dropAll
827
-        return formatter.string(from: effectiveDuration) ?? "0m"
828
-    }
829
-
830
-    private func statusTint(for session: ChargeSessionSummary) -> Color {
831
-        switch session.status {
832
-        case .active:
833
-            return .green
834
-        case .paused:
835
-            return .orange
836
-        case .completed:
837
-            return .teal
838
-        case .abandoned:
839
-            return .secondary
840
-        }
841
-    }
842
-
843 701
     private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
844 702
         switch chargedDevice.deviceClass {
845 703
         case .iphone:
+427 -0
USB Meter/Views/ChargedDevices/ChargedDeviceSessionDetailView.swift
@@ -0,0 +1,427 @@
1
+//
2
+//  ChargedDeviceSessionDetailView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 22/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct ChargedDeviceSessionDetailView: View {
11
+    @EnvironmentObject private var appData: AppData
12
+    @State private var pendingSessionDeletion: ChargeSessionSummary?
13
+    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
14
+
15
+    let chargedDeviceID: UUID
16
+    let sessionID: UUID
17
+
18
+    private var chargedDevice: ChargedDeviceSummary? {
19
+        appData.chargedDeviceSummary(id: chargedDeviceID)
20
+    }
21
+
22
+    private var session: ChargeSessionSummary? {
23
+        chargedDevice?.sessions.first(where: { $0.id == sessionID })
24
+    }
25
+
26
+    var body: some View {
27
+        Group {
28
+            if let chargedDevice, let session {
29
+                ScrollView {
30
+                    VStack(spacing: 16) {
31
+                        overviewCard(session, chargedDevice: chargedDevice)
32
+                        energyCard(session, chargedDevice: chargedDevice)
33
+                        observedMetricsCard(session, chargedDevice: chargedDevice)
34
+                        batteryCard(session)
35
+
36
+                        if !session.displayedAggregatedSamples.isEmpty {
37
+                            storedCurveCard(session)
38
+                        }
39
+
40
+                        managementCard(session)
41
+                    }
42
+                    .padding()
43
+                }
44
+                .background(
45
+                    LinearGradient(
46
+                        colors: [statusTint(for: session).opacity(0.14), Color.clear],
47
+                        startPoint: .topLeading,
48
+                        endPoint: .bottomTrailing
49
+                    )
50
+                    .ignoresSafeArea()
51
+                )
52
+                .navigationTitle("Session Details")
53
+            } else {
54
+                Text("This session is no longer available.")
55
+                    .foregroundColor(.secondary)
56
+                    .navigationTitle("Session")
57
+            }
58
+        }
59
+        .alert(item: $pendingSessionDeletion) { session in
60
+            Alert(
61
+                title: Text("Delete Session?"),
62
+                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
63
+                primaryButton: .destructive(Text("Delete")) {
64
+                    _ = appData.deleteChargeSession(sessionID: session.id)
65
+                },
66
+                secondaryButton: .cancel()
67
+            )
68
+        }
69
+        .alert(item: $pendingCheckpointDeletion) { checkpoint in
70
+            Alert(
71
+                title: Text("Delete Battery Checkpoint"),
72
+                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
73
+                primaryButton: .destructive(Text("Delete")) {
74
+                    _ = appData.deleteBatteryCheckpoint(
75
+                        checkpointID: checkpoint.id,
76
+                        for: checkpoint.sessionID
77
+                    )
78
+                },
79
+                secondaryButton: .cancel()
80
+            )
81
+        }
82
+    }
83
+
84
+    private func overviewCard(
85
+        _ session: ChargeSessionSummary,
86
+        chargedDevice: ChargedDeviceSummary
87
+    ) -> some View {
88
+        MeterInfoCardView(title: "Overview", tint: statusTint(for: session)) {
89
+            MeterInfoRowView(label: "Device", value: chargedDevice.name)
90
+            MeterInfoRowView(label: "Status", value: session.status.title)
91
+            MeterInfoRowView(label: "Started", value: session.startedAt.format())
92
+            if let endedAt = session.endedAt {
93
+                MeterInfoRowView(label: "Ended", value: endedAt.format())
94
+            }
95
+            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
96
+            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
97
+            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
98
+            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
99
+            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
100
+            if session.isTrimmed {
101
+                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
102
+                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
103
+            }
104
+            if let meterName = session.meterName {
105
+                MeterInfoRowView(label: "Meter", value: meterName)
106
+            } else if let meterMACAddress = session.meterMACAddress {
107
+                MeterInfoRowView(label: "Meter", value: meterMACAddress)
108
+            }
109
+            if let meterModel = session.meterModel {
110
+                MeterInfoRowView(label: "Meter Model", value: meterModel)
111
+            }
112
+        }
113
+    }
114
+
115
+    private func energyCard(
116
+        _ session: ChargeSessionSummary,
117
+        chargedDevice: ChargedDeviceSummary
118
+    ) -> some View {
119
+        MeterInfoCardView(title: "Energy", tint: .teal) {
120
+            MeterInfoRowView(label: "Battery Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
121
+            MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
122
+            MeterInfoRowView(label: "Measured Charge", value: "\(session.measuredChargeAh.format(decimalDigits: 3)) Ah")
123
+            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
124
+               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
125
+                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
126
+            }
127
+            if let capacityEstimateWh = session.capacityEstimateWh {
128
+                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
129
+            }
130
+            if let chargerID = session.chargerID,
131
+               let charger = appData.chargedDeviceSummary(id: chargerID) {
132
+                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
133
+            }
134
+            if let wirelessSessionHint = wirelessSessionHint(for: session) {
135
+                Text(wirelessSessionHint)
136
+                    .font(.caption2)
137
+                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
138
+            }
139
+        }
140
+    }
141
+
142
+    private func observedMetricsCard(
143
+        _ session: ChargeSessionSummary,
144
+        chargedDevice: ChargedDeviceSummary
145
+    ) -> some View {
146
+        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
147
+            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
148
+                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
149
+            }
150
+            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
151
+                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
152
+            }
153
+            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
154
+                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
155
+            }
156
+            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
157
+                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
158
+            }
159
+            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
160
+                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
161
+            }
162
+            if let completionCurrentAmps = session.completionCurrentAmps {
163
+                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
164
+            }
165
+            if session.selectedDataGroup != nil {
166
+                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
167
+            }
168
+        }
169
+    }
170
+
171
+    private func batteryCard(_ session: ChargeSessionSummary) -> some View {
172
+        MeterInfoCardView(title: "Battery", tint: .orange) {
173
+            if let startBatteryPercent = session.startBatteryPercent {
174
+                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
175
+            }
176
+            if let endBatteryPercent = session.endBatteryPercent {
177
+                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
178
+            }
179
+            if let batteryDeltaPercent = session.batteryDeltaPercent {
180
+                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
181
+            }
182
+
183
+            BatteryCheckpointSectionView(
184
+                sessionID: session.id,
185
+                checkpoints: session.checkpoints,
186
+                message: "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
187
+                canAddCheckpoint: false,
188
+                requirementMessage: nil,
189
+                effectiveEnergyWhOverride: nil,
190
+                measuredChargeAhOverride: nil,
191
+                onDelete: { checkpoint in
192
+                    pendingCheckpointDeletion = checkpoint
193
+                }
194
+            )
195
+        }
196
+    }
197
+
198
+    private func managementCard(_ session: ChargeSessionSummary) -> some View {
199
+        MeterInfoCardView(title: "Administration", tint: .red) {
200
+            Button(role: .destructive) {
201
+                pendingSessionDeletion = session
202
+            } label: {
203
+                Label("Delete Session", systemImage: "trash")
204
+                    .font(.subheadline.weight(.semibold))
205
+                    .frame(maxWidth: .infinity)
206
+                    .padding(.vertical, 10)
207
+                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
208
+            }
209
+            .buttonStyle(.plain)
210
+        }
211
+    }
212
+
213
+    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
214
+        let displayedSamples = session.displayedAggregatedSamples
215
+        let currentSeries = storedSeriesSnapshot(
216
+            from: displayedSamples,
217
+            minimumYSpan: 0.15
218
+        ) { $0.averageCurrentAmps }
219
+        let energySeries = storedSeriesSnapshot(
220
+            from: displayedSamples,
221
+            minimumYSpan: 0.2
222
+        ) { $0.measuredEnergyWh }
223
+
224
+        return VStack(alignment: .leading, spacing: 14) {
225
+            HStack(alignment: .firstTextBaseline) {
226
+                VStack(alignment: .leading, spacing: 4) {
227
+                    Text("Session Curve")
228
+                        .font(.headline)
229
+                    Text(session.isTrimmed ? "Showing the saved trim window." : "Persisted aggregate samples for this session.")
230
+                        .font(.caption)
231
+                        .foregroundColor(.secondary)
232
+                }
233
+
234
+                Spacer()
235
+
236
+                Text("\(displayedSamples.count) points")
237
+                    .font(.caption.weight(.semibold))
238
+                    .foregroundColor(.secondary)
239
+            }
240
+
241
+            if let currentSeries {
242
+                storedSeriesChart(
243
+                    title: "Current",
244
+                    unit: "A",
245
+                    strokeColor: .blue,
246
+                    snapshot: currentSeries
247
+                )
248
+            }
249
+
250
+            if let energySeries {
251
+                storedSeriesChart(
252
+                    title: "Energy",
253
+                    unit: "Wh",
254
+                    strokeColor: .teal,
255
+                    areaChart: true,
256
+                    snapshot: energySeries
257
+                )
258
+            }
259
+        }
260
+        .frame(maxWidth: .infinity, alignment: .leading)
261
+        .padding(18)
262
+        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
263
+    }
264
+
265
+    private func storedSeriesSnapshot(
266
+        from samples: [ChargeSessionSampleSummary],
267
+        minimumYSpan: Double,
268
+        value: (ChargeSessionSampleSummary) -> Double
269
+    ) -> StoredSessionSeriesSnapshot? {
270
+        let sortedSamples = samples.sorted { lhs, rhs in
271
+            if lhs.bucketIndex != rhs.bucketIndex {
272
+                return lhs.bucketIndex < rhs.bucketIndex
273
+            }
274
+            return lhs.timestamp < rhs.timestamp
275
+        }
276
+
277
+        guard
278
+            let firstSample = sortedSamples.first,
279
+            let lastSample = sortedSamples.last
280
+        else {
281
+            return nil
282
+        }
283
+
284
+        let points = sortedSamples.enumerated().map { index, sample in
285
+            Measurements.Measurement.Point(
286
+                id: index,
287
+                timestamp: sample.timestamp,
288
+                value: value(sample),
289
+                kind: .sample
290
+            )
291
+        }
292
+
293
+        let minimumValue = points.map(\.value).min() ?? 0
294
+        let maximumValue = points.map(\.value).max() ?? minimumValue
295
+        let context = ChartContext()
296
+        context.setBounds(
297
+            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
298
+            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
299
+            yMin: CGFloat(minimumValue),
300
+            yMax: CGFloat(maximumValue)
301
+        )
302
+        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
303
+
304
+        return StoredSessionSeriesSnapshot(
305
+            points: points,
306
+            context: context,
307
+            minimumValue: minimumValue,
308
+            maximumValue: maximumValue
309
+        )
310
+    }
311
+
312
+    private func storedSeriesChart(
313
+        title: String,
314
+        unit: String,
315
+        strokeColor: Color,
316
+        areaChart: Bool = false,
317
+        snapshot: StoredSessionSeriesSnapshot
318
+    ) -> some View {
319
+        VStack(alignment: .leading, spacing: 8) {
320
+            HStack(alignment: .firstTextBaseline) {
321
+                Text(title)
322
+                    .font(.subheadline.weight(.semibold))
323
+                Spacer()
324
+                Text(
325
+                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
326
+                )
327
+                .font(.caption2)
328
+                .foregroundColor(.secondary)
329
+            }
330
+
331
+            TimeSeriesChart(
332
+                points: snapshot.points,
333
+                context: snapshot.context,
334
+                areaChart: areaChart,
335
+                strokeColor: strokeColor
336
+            )
337
+            .frame(height: 118)
338
+            .padding(.horizontal, 6)
339
+            .padding(.vertical, 8)
340
+            .background(
341
+                RoundedRectangle(cornerRadius: 16)
342
+                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
343
+            )
344
+
345
+            HStack {
346
+                Text(snapshot.startLabel)
347
+                Spacer()
348
+                Text(snapshot.endLabel)
349
+            }
350
+            .font(.caption2)
351
+            .foregroundColor(.secondary)
352
+        }
353
+    }
354
+
355
+    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
356
+        let formatter = DateComponentsFormatter()
357
+        let effectiveDuration = max(session.effectiveDuration, 0)
358
+        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
359
+        formatter.unitsStyle = .abbreviated
360
+        formatter.zeroFormattingBehavior = .dropAll
361
+        return formatter.string(from: effectiveDuration) ?? "0m"
362
+    }
363
+
364
+    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
365
+        if session.autoStopEnabled == false {
366
+            return "Manual"
367
+        }
368
+        if session.stopThresholdAmps > 0 {
369
+            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
370
+        }
371
+        return "Learning"
372
+    }
373
+
374
+    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
375
+        guard session.chargingTransportMode == .wireless else {
376
+            return nil
377
+        }
378
+
379
+        var components: [String] = []
380
+        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
381
+            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
382
+        }
383
+        if session.usesEstimatedWirelessEfficiency {
384
+            components.append("Estimated from wired baseline and checkpoints")
385
+        }
386
+        if session.shouldWarnAboutLowWirelessEfficiency {
387
+            components.append("Low wireless efficiency, so capacity confidence is reduced")
388
+        }
389
+
390
+        return components.isEmpty ? nil : components.joined(separator: " - ")
391
+    }
392
+
393
+    private func statusTint(for session: ChargeSessionSummary) -> Color {
394
+        switch session.status {
395
+        case .active:
396
+            return .green
397
+        case .paused:
398
+            return .orange
399
+        case .completed:
400
+            return .teal
401
+        case .abandoned:
402
+            return .secondary
403
+        }
404
+    }
405
+}
406
+
407
+private struct StoredSessionSeriesSnapshot {
408
+    let points: [Measurements.Measurement.Point]
409
+    let context: ChartContext
410
+    let minimumValue: Double
411
+    let maximumValue: Double
412
+
413
+    var lastValue: Double {
414
+        points.last?.value ?? 0
415
+    }
416
+
417
+    var startLabel: String {
418
+        guard let firstTimestamp = points.first?.timestamp else { return "" }
419
+        return firstTimestamp.formatted(date: .omitted, time: .shortened)
420
+    }
421
+
422
+    var endLabel: String {
423
+        guard let lastTimestamp = points.last?.timestamp else { return "" }
424
+        return lastTimestamp.formatted(date: .omitted, time: .shortened)
425
+    }
426
+}
427
+
+263 -0
USB Meter/Views/ChargedDevices/ChargedDeviceSessionsView.swift
@@ -0,0 +1,263 @@
1
+//
2
+//  ChargedDeviceSessionsView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 22/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct ChargedDeviceSessionsView: View {
11
+    @EnvironmentObject private var appData: AppData
12
+    @State private var pendingSessionDeletion: ChargeSessionSummary?
13
+
14
+    let chargedDeviceID: UUID
15
+
16
+    private var chargedDevice: ChargedDeviceSummary? {
17
+        appData.chargedDeviceSummary(id: chargedDeviceID)
18
+    }
19
+
20
+    private var sessions: [ChargeSessionSummary] {
21
+        chargedDevice?.sessions.filter { !$0.status.isOpen } ?? []
22
+    }
23
+
24
+    var body: some View {
25
+        Group {
26
+            if let chargedDevice {
27
+                ScrollView {
28
+                    VStack(spacing: 14) {
29
+                        if sessions.isEmpty {
30
+                            emptyState
31
+                        } else {
32
+                            summaryHeader(chargedDevice)
33
+
34
+                            ForEach(sessions, id: \.id) { session in
35
+                                sessionCard(session, chargedDevice: chargedDevice)
36
+                            }
37
+                        }
38
+                    }
39
+                    .padding()
40
+                }
41
+                .background(
42
+                    LinearGradient(
43
+                        colors: [tint(for: chargedDevice).opacity(0.14), Color.clear],
44
+                        startPoint: .topLeading,
45
+                        endPoint: .bottomTrailing
46
+                    )
47
+                    .ignoresSafeArea()
48
+                )
49
+                .navigationTitle("Sessions")
50
+            } else {
51
+                Text("This device is no longer available.")
52
+                    .foregroundColor(.secondary)
53
+                    .navigationTitle("Sessions")
54
+            }
55
+        }
56
+        .alert(item: $pendingSessionDeletion) { session in
57
+            Alert(
58
+                title: Text("Delete Session?"),
59
+                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
60
+                primaryButton: .destructive(Text("Delete")) {
61
+                    _ = appData.deleteChargeSession(sessionID: session.id)
62
+                },
63
+                secondaryButton: .cancel()
64
+            )
65
+        }
66
+    }
67
+
68
+    private var emptyState: some View {
69
+        VStack(spacing: 10) {
70
+            Image(systemName: "clock")
71
+                .font(.system(size: 34, weight: .semibold))
72
+                .foregroundColor(.secondary)
73
+            Text("No Closed Sessions")
74
+                .font(.headline)
75
+            Text("Completed and abandoned sessions will appear here after they are closed.")
76
+                .font(.footnote)
77
+                .foregroundColor(.secondary)
78
+                .multilineTextAlignment(.center)
79
+        }
80
+        .frame(maxWidth: .infinity)
81
+        .padding(24)
82
+        .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 18)
83
+    }
84
+
85
+    private func summaryHeader(_ chargedDevice: ChargedDeviceSummary) -> some View {
86
+        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
87
+        let completedCount = sessions.filter { $0.status == .completed }.count
88
+
89
+        return MeterInfoCardView(title: chargedDevice.name, tint: tint(for: chargedDevice)) {
90
+            MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)")
91
+            MeterInfoRowView(label: "Completed", value: "\(completedCount)")
92
+            MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
93
+        }
94
+    }
95
+
96
+    private func sessionCard(_ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary) -> some View {
97
+        VStack(alignment: .leading, spacing: 10) {
98
+            NavigationLink(
99
+                destination: ChargedDeviceSessionDetailView(
100
+                    chargedDeviceID: chargedDevice.id,
101
+                    sessionID: session.id
102
+                )
103
+            ) {
104
+                VStack(alignment: .leading, spacing: 10) {
105
+                    HStack(alignment: .firstTextBaseline, spacing: 10) {
106
+                        Text(session.startedAt.format())
107
+                            .font(.subheadline.weight(.semibold))
108
+                            .foregroundColor(.primary)
109
+
110
+                        Text(session.status.title)
111
+                            .font(.caption2.weight(.semibold))
112
+                            .foregroundColor(statusTint(for: session))
113
+                            .padding(.horizontal, 8)
114
+                            .padding(.vertical, 4)
115
+                            .background(
116
+                                Capsule()
117
+                                    .fill(statusTint(for: session).opacity(0.16))
118
+                            )
119
+
120
+                        Spacer()
121
+
122
+                        Image(systemName: "chevron.right")
123
+                            .font(.caption.weight(.semibold))
124
+                            .foregroundColor(.secondary)
125
+                    }
126
+
127
+                    Text(sessionSummaryLine(session, chargedDevice: chargedDevice))
128
+                        .font(.caption)
129
+                        .foregroundColor(.secondary)
130
+
131
+                    LazyVGrid(columns: metricColumns, spacing: 8) {
132
+                        metricCell(label: "Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", tint: .teal)
133
+                        metricCell(label: "Duration", value: sessionDurationText(session), tint: .orange)
134
+                        if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
135
+                            metricCell(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", tint: .blue)
136
+                        }
137
+                        if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
138
+                            metricCell(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A", tint: .indigo)
139
+                        }
140
+                    }
141
+                }
142
+            }
143
+            .buttonStyle(.plain)
144
+
145
+            Divider()
146
+
147
+            HStack {
148
+                if !session.displayedAggregatedSamples.isEmpty {
149
+                    Label("\(session.displayedAggregatedSamples.count) curve points", systemImage: "chart.xyaxis.line")
150
+                        .font(.caption2)
151
+                        .foregroundColor(.secondary)
152
+                }
153
+
154
+                Spacer()
155
+
156
+                Button(role: .destructive) {
157
+                    pendingSessionDeletion = session
158
+                } label: {
159
+                    Image(systemName: "trash")
160
+                        .font(.caption.weight(.semibold))
161
+                        .foregroundColor(.red)
162
+                        .frame(width: 30, height: 30)
163
+                        .background(
164
+                            Circle()
165
+                                .fill(Color.red.opacity(0.10))
166
+                        )
167
+                }
168
+                .buttonStyle(.plain)
169
+                .help("Delete session")
170
+            }
171
+        }
172
+        .padding(14)
173
+        .meterCard(tint: statusTint(for: session), fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
174
+    }
175
+
176
+    private var metricColumns: [GridItem] {
177
+        [
178
+            GridItem(.flexible(minimum: 92), spacing: 8),
179
+            GridItem(.flexible(minimum: 92), spacing: 8)
180
+        ]
181
+    }
182
+
183
+    private func metricCell(label: String, value: String, tint: Color) -> some View {
184
+        VStack(alignment: .leading, spacing: 4) {
185
+            Text(label)
186
+                .font(.caption2)
187
+                .foregroundColor(.secondary)
188
+            Text(value)
189
+                .font(.footnote.weight(.semibold))
190
+                .foregroundColor(.primary)
191
+                .monospacedDigit()
192
+                .lineLimit(1)
193
+                .minimumScaleFactor(0.8)
194
+        }
195
+        .frame(maxWidth: .infinity, alignment: .leading)
196
+        .padding(10)
197
+        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
198
+    }
199
+
200
+    private func sessionSummaryLine(
201
+        _ session: ChargeSessionSummary,
202
+        chargedDevice: ChargedDeviceSummary
203
+    ) -> String {
204
+        var components: [String] = []
205
+
206
+        if let batteryDeltaPercent = session.batteryDeltaPercent {
207
+            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
208
+        }
209
+        if let capacityEstimateWh = session.capacityEstimateWh {
210
+            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
211
+        }
212
+        if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
213
+            components.append(session.chargingTransportMode.title)
214
+        }
215
+        if chargedDevice.shouldShowChargingStateMode(session.chargingStateMode) {
216
+            components.append(session.chargingStateMode.title)
217
+        }
218
+        if session.isTrimmed {
219
+            components.append("Trimmed")
220
+        }
221
+        components.append(session.sourceMode.title)
222
+
223
+        return components.joined(separator: " - ")
224
+    }
225
+
226
+    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
227
+        let formatter = DateComponentsFormatter()
228
+        let effectiveDuration = max(session.effectiveDuration, 0)
229
+        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
230
+        formatter.unitsStyle = .abbreviated
231
+        formatter.zeroFormattingBehavior = .dropAll
232
+        return formatter.string(from: effectiveDuration) ?? "0m"
233
+    }
234
+
235
+    private func statusTint(for session: ChargeSessionSummary) -> Color {
236
+        switch session.status {
237
+        case .active:
238
+            return .green
239
+        case .paused:
240
+            return .orange
241
+        case .completed:
242
+            return .teal
243
+        case .abandoned:
244
+            return .secondary
245
+        }
246
+    }
247
+
248
+    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
249
+        switch chargedDevice.deviceClass {
250
+        case .iphone:
251
+            return .blue
252
+        case .watch:
253
+            return .green
254
+        case .powerbank:
255
+            return .orange
256
+        case .charger:
257
+            return .pink
258
+        case .other:
259
+            return .secondary
260
+        }
261
+    }
262
+}
263
+