Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
64 changes: 61 additions & 3 deletions apps/decodex/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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::<Vec<_>>();

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();
}
}
}
Expand Down Expand Up @@ -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,
Expand Down