From 51503dd111f097b9301a70be96ca73831ad37fe8 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 29 May 2026 11:41:09 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Restore Decodex app usage details","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 60 +++++++++++++++-- .../Sources/DecodexApp/DecodexAppBridge.swift | 2 +- apps/decodex/src/accounts.rs | 64 ++++++++++++++++++- 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 26887c3f..c9be6c8f 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -870,20 +870,29 @@ struct AccountRunSummaryView: View { var body: some View { ViewThatFits(in: .horizontal) { - runRow(visibleCount: 3) - runRow(visibleCount: 2) - runRow(visibleCount: 1) + runRow(visibleCount: 2, style: .detailed) + runRow(visibleCount: 1, style: .detailed) + runRow(visibleCount: 3, style: .compact) + runRow(visibleCount: 2, style: .compact) + runRow(visibleCount: 1, style: .compact) } .frame(maxWidth: .infinity, alignment: .leading) } - private func runRow(visibleCount: Int) -> some View { + private func runRow( + visibleCount: Int, + style: AccountRunChipStyle + ) -> some View { let visibleRuns = Array(runs.prefix(visibleCount)) let hiddenRuns = Array(runs.dropFirst(visibleCount)) return HStack(spacing: 5) { ForEach(visibleRuns) { run in - AccountRunChipView(run: run) + AccountRunChipView( + run: run, + style: style, + maxWidth: chipMaxWidth(style: style, visibleCount: visibleRuns.count) + ) } if hiddenRuns.isEmpty == false { @@ -892,6 +901,20 @@ struct AccountRunSummaryView: View { } .fixedSize(horizontal: true, vertical: false) } + + private func chipMaxWidth( + style: AccountRunChipStyle, + visibleCount: Int + ) -> CGFloat { + switch style { + case .detailed: + return visibleCount <= 1 + ? AccountRunChipLayout.wideDetailedMaxWidth + : AccountRunChipLayout.detailedMaxWidth + case .compact: + return AccountRunChipLayout.compactMaxWidth + } + } } private enum AccountPanelLayout { @@ -962,11 +985,20 @@ private struct AccountListScrollIndicatorView: View { } private enum AccountRunChipLayout { - static let maxWidth: CGFloat = 108 + static let compactMaxWidth: CGFloat = 108 + static let detailedMaxWidth: CGFloat = 132 + static let wideDetailedMaxWidth: CGFloat = 218 +} + +enum AccountRunChipStyle { + case detailed + case compact } struct AccountRunChipView: View { let run: OperatorRunStatus + let style: AccountRunChipStyle + let maxWidth: CGFloat @Environment(\.colorScheme) private var colorScheme @State private var isHovered = false @State private var showsPopover = false @@ -983,10 +1015,24 @@ struct AccountRunChipView: View { .foregroundStyle(PanelPalette.primaryText(colorScheme).opacity(0.92)) .lineLimit(1) .truncationMode(.middle) + .fixedSize(horizontal: true, vertical: false) + + if style == .detailed { + Text("ยท") + .font(PanelFont.metricLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.62)) + .fixedSize(horizontal: true, vertical: false) + + Text(run.compactDetail) + .font(PanelFont.metricLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + .truncationMode(.tail) + } } .frame(height: 21) .padding(.horizontal, 8) - .frame(maxWidth: AccountRunChipLayout.maxWidth, alignment: .leading) + .frame(maxWidth: maxWidth, alignment: .leading) .background { RoundedRectangle(cornerRadius: 10.5, style: .continuous) .fill(isHovered ? tint.opacity(colorScheme == .dark ? 0.09 : 0.07) : Color.clear) diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift index 07a08788..0546a89f 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift @@ -200,7 +200,7 @@ struct DecodexAppBridge: Sendable { as type: T.Type, onOutput: (@MainActor @Sendable (String) -> Void)? ) async throws -> T { - if onOutput == nil, try request.serverRoute() != nil { + if request.requiresHelper == false, onOutput == nil, try request.serverRoute() != nil { return try await DecodexServerBridge.shared.run(request, as: type) } guard request.requiresHelper else { diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs index b82ff718..7b98a69b 100644 --- a/apps/decodex/src/accounts.rs +++ b/apps/decodex/src/accounts.rs @@ -506,6 +506,7 @@ impl AccountListResponse { .merge_current_records(self.accounts.iter().filter_map(AccountSummary::usage_record)); history.write(&history_path)?; history.apply_to_accounts(&mut self.accounts); + self.refresh_usage_estimate(); Ok(()) } @@ -833,12 +834,23 @@ impl AccountUsageHistory { fn apply_to_accounts(&self, accounts: &mut [AccountSummary]) { for account in accounts { - account.usage_records = self + let matching_records = self .records .iter() .filter(|record| record.matches_account(account)) - .map(AccountUsageHistoryRecord::daily_summary) - .collect(); + .collect::>(); + + if account.seven_day_used_percent.is_none() + && let Some(latest) = + matching_records.iter().max_by_key(|record| record.checked_at_unix_epoch) + { + account.seven_day_used_percent = Some(latest.used_percent); + account.seven_day_daily_average_percent = + Some(latest.used_percent as f64 / USAGE_ESTIMATE_WINDOW_DAYS as f64); + } + + account.usage_records = + matching_records.iter().map(|record| record.daily_summary()).collect(); } } } @@ -2095,6 +2107,52 @@ mod tests { assert!(history.contains(r#""used_percent":30"#)); } + #[test] + fn usage_history_backfills_seven_day_estimate_when_current_windows_are_absent() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let store = AccountStore::new( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + ); + + store + .save_records(&[account_record( + "copy@example.com", + "acct_123456", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret", + )]) + .expect("records should save"); + + let history_path = super::usage_history_path(&store.accounts_path) + .expect("usage history path should resolve"); + + fs::create_dir_all(history_path.parent().expect("history path should have parent")) + .expect("history dir should create"); + fs::write( + &history_path, + r#"{"date":"2026-05-27","account_fingerprint":"...123456","email":"copy@example.com","used_percent":22,"window_seconds":604800,"checked_at_unix_epoch":1800000000,"resets_at_unix_epoch":1800604800} +{"date":"2026-05-28","account_fingerprint":"...123456","email":"copy@example.com","used_percent":63,"window_seconds":604800,"checked_at_unix_epoch":1800000100,"resets_at_unix_epoch":1800604900} +"#, + ) + .expect("usage history should write"); + + let mut response = store.list().expect("account list should load"); + + response.refresh_usage_records(&store.accounts_path).expect("usage history should refresh"); + + let estimate = response.usage_estimate.as_ref().expect("usage estimate should exist"); + + assert_eq!(response.accounts[0].primary_remaining_percent, None); + assert_eq!(response.accounts[0].seven_day_used_percent, Some(63)); + + assert_close(response.accounts[0].seven_day_daily_average_percent, 63.0 / 7.0); + + assert_eq!(response.accounts[0].usage_records.len(), 2); + assert_eq!(estimate.account_estimate_count, 1); + assert_eq!(estimate.total_used_percent, 63); + } + fn account_record( email: &str, account_id: &str,