@@ -27,6 +27,23 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String {
2727 " \( L ( " Coverage " ) ) : \( codexBarLocalizedInteger ( covered) ) / \( codexBarLocalizedInteger ( requested) ) "
2828}
2929
30+ func codexCostCatchUpProgressText( _ activity: CodexCostCatchUpActivity ) -> String {
31+ if activity. totalBytes > 0 {
32+ let processed = ByteCountFormatter . string (
33+ fromByteCount: activity. processedBytes,
34+ countStyle: . file)
35+ let total = ByteCountFormatter . string (
36+ fromByteCount: activity. totalBytes,
37+ countStyle: . file)
38+ return " \( processed) / \( total) "
39+ }
40+ if activity. totalFiles > 0 {
41+ return " \( codexBarLocalizedInteger ( activity. completedFiles) ) / "
42+ + codexBarLocalizedInteger( activity. totalFiles)
43+ }
44+ return L ( " Loading… " )
45+ }
46+
3047enum SpendDashboardModelHistoryPresentation : Equatable {
3148 case unavailable
3249 case empty
@@ -48,6 +65,7 @@ struct SpendDashboardPane: View {
4865 @Bindable var settings : SettingsStore
4966 @Bindable var store : UsageStore
5067 @State private var controller : SpendDashboardController
68+ @State private var isVisible = false
5169
5270 init ( settings: SettingsStore , store: UsageStore ) {
5371 self . settings = settings
@@ -61,6 +79,7 @@ struct SpendDashboardPane: View {
6179 ScrollView {
6280 VStack ( alignment: . leading, spacing: 18 ) {
6381 self . header
82+ self . codexCostCatchUpPanel
6483 self . content
6584 self . provenance
6685 self . shareAction
@@ -69,13 +88,26 @@ struct SpendDashboardPane: View {
6988 }
7089 . background ( FocusResigningBackground ( ) )
7190 . onAppear {
91+ self . isVisible = true
7292 self . controller. refreshDateWindow ( )
7393 self . controller. update ( configuration: self . configuration)
94+ if !self . controller. isRefreshing {
95+ self . synchronizeCodexCostCatchUp ( )
96+ }
7497 }
7598 . onChange ( of: self . configuration) { _, configuration in
7699 self . controller. update ( configuration: configuration)
100+ if self . isVisible, !self . controller. isRefreshing {
101+ self . synchronizeCodexCostCatchUp ( )
102+ }
103+ }
104+ . onChange ( of: self . controller. isRefreshing) { _, isRefreshing in
105+ if self . isVisible, !isRefreshing {
106+ self . synchronizeCodexCostCatchUp ( )
107+ }
77108 }
78109 . onDisappear {
110+ self . isVisible = false
79111 self . controller. stop ( )
80112 }
81113 . onReceive ( NotificationCenter . default. publisher ( for: . NSCalendarDayChanged) ) { _ in
@@ -124,6 +156,131 @@ struct SpendDashboardPane: View {
124156 }
125157 }
126158
159+ @ViewBuilder
160+ private var codexCostCatchUpPanel : some View {
161+ if let activity = self . store. spendDashboardCodexCostCatchUpActivity,
162+ activity. phase != . complete
163+ {
164+ SpendDashboardPanel {
165+ VStack ( alignment: . leading, spacing: 10 ) {
166+ HStack ( spacing: 8 ) {
167+ Label (
168+ self . codexCostCatchUpTitle ( activity) ,
169+ systemImage: activity. phase == . paused ? " pause.circle " : " externaldrive " )
170+ . font ( . headline)
171+ Spacer ( )
172+ Text ( codexCostCatchUpProgressText ( activity) )
173+ . font ( . caption. monospacedDigit ( ) )
174+ . foregroundStyle ( . secondary)
175+ }
176+
177+ if let progress = activity. fractionCompleted {
178+ ProgressView ( value: progress)
179+ } else if activity. phase == . indexing {
180+ ProgressView ( )
181+ . controlSize ( . small)
182+ }
183+
184+ if let staleSnapshotUpdatedAt = activity. staleSnapshotUpdatedAt {
185+ HStack ( spacing: 6 ) {
186+ Label ( L ( " stale data " ) , systemImage: " clock.badge.exclamationmark " )
187+ Text ( L (
188+ " Updated relative %@ " ,
189+ staleSnapshotUpdatedAt. relativeDescription ( ) ) )
190+ }
191+ . font ( . caption. weight ( . medium) )
192+ . foregroundStyle ( . orange)
193+ }
194+
195+ Text ( self . codexCostCatchUpDetail ( activity) )
196+ . font ( . caption)
197+ . foregroundStyle ( . secondary)
198+
199+ HStack {
200+ if activity. pauseReason == . user
201+ || activity. pauseReason == . noProgress
202+ || self . codexCostCatchUpHasError ( activity)
203+ {
204+ Button ( L ( " Refresh " ) ) {
205+ self . startCodexCostCatchUp ( mode: . automatic)
206+ }
207+ } else if activity. mode == . automatic {
208+ Button ( L ( " Finish now " ) ) {
209+ self . startCodexCostCatchUp ( mode: . accelerated)
210+ }
211+ } else {
212+ Button ( L ( " Continue in background " ) ) {
213+ self . startCodexCostCatchUp ( mode: . automatic)
214+ }
215+ }
216+
217+ if activity. pauseReason != . user,
218+ activity. pauseReason != . noProgress,
219+ !self . codexCostCatchUpHasError ( activity)
220+ {
221+ Button ( L ( " Cancel " ) ) {
222+ self . store. stopSpendDashboardCodexCostCatchUp ( )
223+ }
224+ }
225+ }
226+ . controlSize ( . small)
227+ }
228+ }
229+ }
230+ }
231+
232+ private func codexCostCatchUpHasError( _ activity: CodexCostCatchUpActivity ) -> Bool {
233+ if case . error = activity. pauseReason {
234+ return true
235+ }
236+ return false
237+ }
238+
239+ private func synchronizeCodexCostCatchUp( ) {
240+ self . store. synchronizeSpendDashboardCodexCostCatchUp (
241+ accounts: self . codexSpendScanRequests)
242+ }
243+
244+ private func startCodexCostCatchUp( mode: CodexCostCatchUpMode ) {
245+ self . store. startSpendDashboardCodexCostCatchUpIfNeeded (
246+ accounts: self . codexSpendScanRequests,
247+ mode: mode)
248+ }
249+
250+ private var codexSpendScanRequests : [ CodexSpendScanRequest ] {
251+ guard self . configuration. costUsageEnabled,
252+ self . configuration. providerIDs. contains ( UsageProvider . codex. rawValue)
253+ else { return [ ] }
254+ return SpendDashboardSource . codexRequests ( settings: self . settings, store: self . store)
255+ }
256+
257+ private func codexCostCatchUpTitle( _ activity: CodexCostCatchUpActivity ) -> String {
258+ let prefix = L ( " Local estimated history " )
259+ switch activity. phase {
260+ case . indexing:
261+ return " \( prefix) · \( L ( " Refreshing " ) ) "
262+ case . paused:
263+ return " \( prefix) · \( L ( " Inactive " ) ) "
264+ case . complete:
265+ return " \( prefix) · \( L ( " Done " ) ) "
266+ }
267+ }
268+
269+ private func codexCostCatchUpDetail( _ activity: CodexCostCatchUpActivity ) -> String {
270+ switch activity. pauseReason {
271+ case . lowPower:
272+ L ( " Battery Saver " )
273+ case . thermal, . user:
274+ L ( " Inactive " )
275+ case . noProgress:
276+ L ( " Error " )
277+ case let . error( message) :
278+ L ( " cost_status_error " , L ( " Cost " ) , message)
279+ case nil :
280+ L ( " Estimated from local Codex logs for the selected account. " )
281+ }
282+ }
283+
127284 @ViewBuilder
128285 private var content : some View {
129286 if !self . settings. costUsageEnabled {
0 commit comments